diff --git a/.docker/frankenphp/Caddyfile b/.docker/frankenphp/Caddyfile
new file mode 100644
index 00000000..83839304
--- /dev/null
+++ b/.docker/frankenphp/Caddyfile
@@ -0,0 +1,55 @@
+{
+ {$CADDY_GLOBAL_OPTIONS}
+
+ frankenphp {
+ {$FRANKENPHP_CONFIG}
+ }
+
+ # https://caddyserver.com/docs/caddyfile/directives#sorting-algorithm
+ order mercure after encode
+ order vulcain after reverse_proxy
+ order php_server before file_server
+}
+
+{$CADDY_EXTRA_CONFIG}
+
+{$SERVER_NAME:localhost} {
+ log {
+ # Redact the authorization query parameter that can be set by Mercure
+ format filter {
+ wrap console
+ fields {
+ uri query {
+ replace authorization REDACTED
+ }
+ }
+ }
+ }
+
+ root * /app/public
+ encode zstd br gzip
+
+ mercure {
+ # Transport to use (default to Bolt)
+ transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
+ # Publisher JWT key
+ publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
+ # Subscriber JWT key
+ subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG}
+ # Allow anonymous subscribers (double-check that it's what you want)
+ anonymous
+ # Enable the subscription API (double-check that it's what you want)
+ subscriptions
+ # Extra directives
+ {$MERCURE_EXTRA_DIRECTIVES}
+ }
+
+ vulcain
+
+ {$CADDY_SERVER_EXTRA_DIRECTIVES}
+
+ # Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics
+ header ?Permissions-Policy "browsing-topics=()"
+
+ php_server
+}
diff --git a/.docker/frankenphp/conf.d/app.dev.ini b/.docker/frankenphp/conf.d/app.dev.ini
new file mode 100644
index 00000000..e50f43d0
--- /dev/null
+++ b/.docker/frankenphp/conf.d/app.dev.ini
@@ -0,0 +1,5 @@
+; See https://docs.docker.com/desktop/networking/#i-want-to-connect-from-a-container-to-a-service-on-the-host
+; See https://github.com/docker/for-linux/issues/264
+; The `client_host` below may optionally be replaced with `discover_client_host=yes`
+; Add `start_with_request=yes` to start debug session on each request
+xdebug.client_host = host.docker.internal
diff --git a/.docker/frankenphp/conf.d/app.ini b/.docker/frankenphp/conf.d/app.ini
new file mode 100644
index 00000000..10a062f2
--- /dev/null
+++ b/.docker/frankenphp/conf.d/app.ini
@@ -0,0 +1,18 @@
+expose_php = 0
+date.timezone = UTC
+apc.enable_cli = 1
+session.use_strict_mode = 1
+zend.detect_unicode = 0
+
+; https://symfony.com/doc/current/performance.html
+realpath_cache_size = 4096K
+realpath_cache_ttl = 600
+opcache.interned_strings_buffer = 16
+opcache.max_accelerated_files = 20000
+opcache.memory_consumption = 256
+opcache.enable_file_override = 1
+
+memory_limit = 256M
+
+upload_max_filesize=256M
+post_max_size=300M
\ No newline at end of file
diff --git a/.docker/frankenphp/conf.d/app.prod.ini b/.docker/frankenphp/conf.d/app.prod.ini
new file mode 100644
index 00000000..3bcaa71e
--- /dev/null
+++ b/.docker/frankenphp/conf.d/app.prod.ini
@@ -0,0 +1,2 @@
+opcache.preload_user = root
+opcache.preload = /app/config/preload.php
diff --git a/.docker/frankenphp/docker-entrypoint.sh b/.docker/frankenphp/docker-entrypoint.sh
new file mode 100644
index 00000000..1655af5a
--- /dev/null
+++ b/.docker/frankenphp/docker-entrypoint.sh
@@ -0,0 +1,60 @@
+#!/bin/sh
+set -e
+
+if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
+ # Install the project the first time PHP is started
+ # After the installation, the following block can be deleted
+ if [ ! -f composer.json ]; then
+ rm -Rf tmp/
+ composer create-project "symfony/skeleton $SYMFONY_VERSION" tmp --stability="$STABILITY" --prefer-dist --no-progress --no-interaction --no-install
+
+ cd tmp
+ cp -Rp . ..
+ cd -
+ rm -Rf tmp/
+
+ composer require "php:>=$PHP_VERSION" runtime/frankenphp-symfony
+ composer config --json extra.symfony.docker 'true'
+
+ if grep -q ^DATABASE_URL= .env; then
+ echo "To finish the installation please press Ctrl+C to stop Docker Compose and run: docker compose up --build -d --wait"
+ sleep infinity
+ fi
+ fi
+
+ if [ -z "$(ls -A 'vendor/' 2>/dev/null)" ]; then
+ composer install --prefer-dist --no-progress --no-interaction
+ fi
+
+ if grep -q ^DATABASE_URL= .env; then
+ echo "Waiting for database to be ready..."
+ ATTEMPTS_LEFT_TO_REACH_DATABASE=60
+ until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(php bin/console dbal:run-sql -q "SELECT 1" 2>&1); do
+ if [ $? -eq 255 ]; then
+ # If the Doctrine command exits with 255, an unrecoverable error occurred
+ ATTEMPTS_LEFT_TO_REACH_DATABASE=0
+ break
+ fi
+ sleep 1
+ ATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1))
+ echo "Still waiting for database to be ready... Or maybe the database is not reachable. $ATTEMPTS_LEFT_TO_REACH_DATABASE attempts left."
+ done
+
+ if [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ]; then
+ echo "The database is not up or not reachable:"
+ echo "$DATABASE_ERROR"
+ exit 1
+ else
+ echo "The database is now ready and reachable"
+ fi
+
+ if [ "$( find ./migrations -iname '*.php' -print -quit )" ]; then
+ php bin/console doctrine:migrations:migrate --no-interaction
+ fi
+ fi
+
+ setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var
+ setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var
+fi
+
+exec docker-php-entrypoint "$@"
\ No newline at end of file
diff --git a/.docker/frankenphp/worker.Caddyfile b/.docker/frankenphp/worker.Caddyfile
new file mode 100644
index 00000000..d384ae4c
--- /dev/null
+++ b/.docker/frankenphp/worker.Caddyfile
@@ -0,0 +1,4 @@
+worker {
+ file ./public/index.php
+ env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
+}
diff --git a/.docker/partdb-entrypoint.sh b/.docker/partdb-entrypoint.sh
index e73bc83b..ffd2b24a 100644
--- a/.docker/partdb-entrypoint.sh
+++ b/.docker/partdb-entrypoint.sh
@@ -39,6 +39,51 @@ if [ -d /var/www/html/var/db ]; then
fi
fi
+# Start PHP-FPM (the PHP_VERSION is replaced by the configured version in the Dockerfile)
+service phpPHP_VERSION-fpm start
+
+
+# Run migrations if automigration is enabled via env variable DB_AUTOMIGRATE
+if [ "$DB_AUTOMIGRATE" = "true" ]; then
+ echo "Waiting for database to be ready..."
+ ATTEMPTS_LEFT_TO_REACH_DATABASE=60
+ until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(sudo -E -u www-data php bin/console dbal:run-sql -q "SELECT 1" 2>&1); do
+ if [ $? -eq 255 ]; then
+ # If the Doctrine command exits with 255, an unrecoverable error occurred
+ ATTEMPTS_LEFT_TO_REACH_DATABASE=0
+ break
+ fi
+ sleep 1
+ ATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1))
+ echo "Still waiting for database to be ready... Or maybe the database is not reachable. $ATTEMPTS_LEFT_TO_REACH_DATABASE attempts left."
+ done
+
+ if [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ]; then
+ echo "The database is not up or not reachable:"
+ echo "$DATABASE_ERROR"
+ exit 1
+ else
+ echo "The database is now ready and reachable"
+ fi
+
+ # Check if there are any available migrations to do, by executing doctrine:migrations:up-to-date
+ # and checking if the exit code is 0 (up to date) or 1 (not up to date)
+ if sudo -E -u www-data php bin/console doctrine:migrations:up-to-date --no-interaction; then
+ echo "Database is up to date, no migrations necessary."
+ else
+ echo "Migrations available..."
+ echo "Do backup of database..."
+
+ sudo -E -u www-data mkdir -p /var/www/html/uploads/.automigration-backup/
+ # Backup the database
+ sudo -E -u www-data php bin/console partdb:backup -n --database /var/www/html/uploads/.automigration-backup/backup-$(date +%Y-%m-%d_%H-%M-%S).zip
+
+ # Check if there are any migration files
+ sudo -E -u www-data php bin/console doctrine:migrations:migrate --no-interaction
+ fi
+
+fi
+
# first arg is `-f` or `--some-option` (taken from https://github.com/docker-library/php/blob/master/8.2/bullseye/apache/docker-php-entrypoint)
if [ "${1#-}" != "$1" ]; then
set -- apache2-foreground "$@"
diff --git a/.docker/symfony.conf b/.docker/symfony.conf
index 01aa91f0..b5229bf6 100644
--- a/.docker/symfony.conf
+++ b/.docker/symfony.conf
@@ -25,14 +25,28 @@
CustomLog ${APACHE_LOG_DIR}/access.log combined
# Pass the configuration from the docker env to the PHP environment (here you should list all .env options)
- PassEnv APP_ENV APP_DEBUG APP_SECRET
- PassEnv DATABASE_URL
- PassEnv DEFAULT_LANG DEFAULT_TIMEZONE BASE_CURRENCY INSTANCE_NAME ALLOW_ATTACHMENT_DOWNLOADS USE_GRAVATAR
+ PassEnv APP_ENV APP_DEBUG APP_SECRET REDIRECT_TO_HTTPS DISABLE_YEAR2038_BUG_CHECK
+ PassEnv TRUSTED_PROXIES TRUSTED_HOSTS LOCK_DSN
+ PassEnv DATABASE_URL ENFORCE_CHANGE_COMMENTS_FOR DATABASE_MYSQL_USE_SSL_CA DATABASE_MYSQL_SSL_VERIFY_CERT
+ PassEnv DEFAULT_LANG DEFAULT_TIMEZONE BASE_CURRENCY INSTANCE_NAME ALLOW_ATTACHMENT_DOWNLOADS USE_GRAVATAR MAX_ATTACHMENT_FILE_SIZE DEFAULT_URI CHECK_FOR_UPDATES ATTACHMENT_DOWNLOAD_BY_DEFAULT
PassEnv MAILER_DSN ALLOW_EMAIL_PW_RESET EMAIL_SENDER_EMAIL EMAIL_SENDER_NAME
- PassEnv HISTORY_SAVE_CHANGED_FIELDS HISTORY_SAVE_CHANGED_DATA HISTORY_SAVE_REMOVED_DATA
+ PassEnv HISTORY_SAVE_CHANGED_FIELDS HISTORY_SAVE_CHANGED_DATA HISTORY_SAVE_REMOVED_DATA HISTORY_SAVE_NEW_DATA
PassEnv ERROR_PAGE_ADMIN_EMAIL ERROR_PAGE_SHOW_HELP
PassEnv DEMO_MODE NO_URL_REWRITE_AVAILABLE FIXER_API_KEY BANNER
+ # In old version the SAML sp private key env, was wrongly named SAMLP_SP_PRIVATE_KEY, keep it for backward compatibility
+ PassEnv SAML_ENABLED SAML_BEHIND_PROXY 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 SAML_SP_PRIVATE_KEY SAMLP_SP_PRIVATE_KEY
+ PassEnv TABLE_DEFAULT_PAGE_SIZE TABLE_PARTS_DEFAULT_COLUMNS
+ PassEnv PROVIDER_DIGIKEY_CLIENT_ID PROVIDER_DIGIKEY_SECRET PROVIDER_DIGIKEY_CURRENCY PROVIDER_DIGIKEY_LANGUAGE PROVIDER_DIGIKEY_COUNTRY
+ PassEnv PROVIDER_ELEMENT14_KEY PROVIDER_ELEMENT14_STORE_ID
+ PassEnv PROVIDER_TME_KEY PROVIDER_TME_SECRET PROVIDER_TME_CURRENCY PROVIDER_TME_LANGUAGE PROVIDER_TME_COUNTRY PROVIDER_TME_GET_GROSS_PRICES
+ PassEnv PROVIDER_OCTOPART_CLIENT_ID PROVIDER_OCTOPART_SECRET PROVIDER_OCTOPART_CURRENCY PROVIDER_OCTOPART_COUNTRY PROVIDER_OCTOPART_SEARCH_LIMIT PROVIDER_OCTOPART_ONLY_AUTHORIZED_SELLERS
+ PassEnv PROVIDER_MOUSER_KEY PROVIDER_MOUSER_SEARCH_OPTION PROVIDER_MOUSER_SEARCH_LIMIT PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE
+ PassEnv PROVIDER_LCSC_ENABLED PROVIDER_LCSC_CURRENCY
+ PassEnv PROVIDER_OEMSECRETS_KEY PROVIDER_OEMSECRETS_COUNTRY_CODE PROVIDER_OEMSECRETS_CURRENCY PROVIDER_OEMSECRETS_ZERO_PRICE PROVIDER_OEMSECRETS_SET_PARAM PROVIDER_OEMSECRETS_SORT_CRITERIA
+ PassEnv PROVIDER_REICHELT_ENABLED PROVIDER_REICHELT_CURRENCY PROVIDER_REICHELT_COUNTRY PROVIDER_REICHELT_LANGUAGE PROVIDER_REICHELT_INCLUDE_VAT
+ PassEnv PROVIDER_POLLIN_ENABLED
+ PassEnv EDA_KICAD_CATEGORY_DEPTH
# For most configuration files from conf-available/, which are
# enabled or disabled at a global level, it is possible to
diff --git a/.dockerignore b/.dockerignore
index 8929729c..472b1bb3 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -5,6 +5,8 @@ tests/
docs/
.git
+/public/media/*
+
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
@@ -42,3 +44,39 @@ yarn-error.log
/phpunit.xml
.phpunit.result.cache
###< phpunit/phpunit ###
+
+
+### From frankenphp
+
+**/*.log
+**/*.php~
+**/*.dist.php
+**/*.dist
+**/*.cache
+**/._*
+**/.dockerignore
+**/.DS_Store
+**/.git/
+**/.gitattributes
+**/.gitignore
+**/.gitmodules
+**/compose.*.yaml
+**/compose.*.yml
+**/compose.yaml
+**/compose.yml
+**/docker-compose.*.yaml
+**/docker-compose.*.yml
+**/docker-compose.yaml
+**/docker-compose.yml
+**/Dockerfile
+**/Thumbs.db
+.github/
+public/bundles/
+var/
+vendor/
+.editorconfig
+.env.*.local
+.env.local
+.env.local.php
+.env.test
+
diff --git a/.env b/.env
index 0e8adff6..1806e9c6 100644
--- a/.env
+++ b/.env
@@ -14,6 +14,19 @@ DATABASE_URL="sqlite:///%kernel.project_dir%/var/app.db"
# Uncomment this line (and comment the line above to use a MySQL database
#DATABASE_URL=mysql://root:@127.0.0.1:3306/part-db?serverVersion=5.7
+# Set this value to 1, if you want to use SSL to connect to the MySQL server. It will be tried to use the CA certificate
+# otherwise a CA bundle shipped with PHP will be used.
+# Leave it at 0, if you do not want to use SSL or if your server does not support it
+DATABASE_MYSQL_USE_SSL_CA=0
+
+# Set this value to 0, if you don't want to verify the CA certificate of the MySQL server
+# Only do this, if you know what you are doing!
+DATABASE_MYSQL_SSL_VERIFY_CERT=1
+
+# Emulate natural sorting of strings even on databases that do not support it (like SQLite, MySQL or MariaDB < 10.7)
+# This can be slow on big databases and might have some problems and quirks, so use it with caution
+DATABASE_EMULATE_NATURAL_SORT=0
+
###################################################################################
# General settings
###################################################################################
@@ -29,8 +42,25 @@ INSTANCE_NAME="Part-DB"
# Allow users to download attachments to the server by providing an URL
# This could be a potential security issue, as the user can retrieve any file the server has access to (via internet)
ALLOW_ATTACHMENT_DOWNLOADS=0
+# Set this to 1, if the "download external files" checkbox should be checked by default for new attachments
+ATTACHMENT_DOWNLOAD_BY_DEFAULT=0
# Use gravatars for user avatars, when user has no own avatar defined
USE_GRAVATAR=0
+# The maximum allowed size for attachment files in bytes (you can use M for megabytes and G for gigabytes)
+# Please note that the php.ini setting upload_max_filesize also limits the maximum size of uploaded files
+MAX_ATTACHMENT_FILE_SIZE="100M"
+
+# The public reachable URL of this Part-DB installation. This is used for generating links in SAML and email templates
+# This must end with a slash!
+DEFAULT_URI="https://partdb.changeme.invalid/"
+
+# With this option you can configure, where users are enforced to give a change reason, which will be logged
+# This is a comma separated list of values, see documentation for available values
+# Leave this empty, to make all change reasons optional
+ENFORCE_CHANGE_COMMENTS_FOR=""
+
+# Disable that if you do not want that Part-DB connects to GitHub to check for available updates, or if your server can not connect to the internet
+CHECK_FOR_UPDATES=1
###################################################################################
# Email settings
@@ -59,6 +89,9 @@ HISTORY_SAVE_CHANGED_FIELDS=1
HISTORY_SAVE_CHANGED_DATA=1
# Save the data of an element that gets removed into log entry. This allows to undelete an element
HISTORY_SAVE_REMOVED_DATA=1
+# Save the new data of an element that gets changed or added. This allows an easy comparison of the old and new data on the detail page
+# This option only becomes active when HISTORY_SAVE_CHANGED_DATA is set to 1
+HISTORY_SAVE_NEW_DATA=1
###################################################################################
# Error pages settings
@@ -69,6 +102,189 @@ ERROR_PAGE_ADMIN_EMAIL=''
# If this is set to true, solutions to common problems are shown on error pages. Disable this, if you do not want your users to see them...
ERROR_PAGE_SHOW_HELP=1
+##################################################################################
+# Part table settings
+##################################################################################
+
+# The default page size for the part table (set to -1 to show all parts on one page)
+TABLE_DEFAULT_PAGE_SIZE=50
+# Configure which columns will be visible by default in the parts table (and in which order).
+# This is a comma separated list of column names. See documentation for available values.
+TABLE_PARTS_DEFAULT_COLUMNS=name,description,category,footprint,manufacturer,storage_location,amount
+
+##################################################################################
+# 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
+# [DEPRECATED] Set this to 1 to get gross prices (including VAT) instead of net prices
+# With private API keys, this option cannot be used anymore is ignored by Part-DB. The VAT inclusion depends on your TME account settings.
+PROVIDER_TME_GET_GROSS_PRICES=1
+
+# Octopart / Nexar Provider:
+# You can get your API key from https://nexar.com/api
+PROVIDER_OCTOPART_CLIENT_ID=
+PROVIDER_OCTOPART_SECRET=
+# The currency and country to get prices for (you have to set both to get meaningful results)
+# 3 letter ISO currency code (e.g. EUR, USD, GBP)
+PROVIDER_OCTOPART_CURRENCY=EUR
+# 2 letter ISO country code (e.g. DE, US, GB)
+PROVIDER_OCTOPART_COUNTRY=DE
+# The number of results to get from Octopart while searching (please note that this counts towards your API limits)
+PROVIDER_OCTOPART_SEARCH_LIMIT=10
+# Set to false to include non authorized offers in the results
+PROVIDER_OCTOPART_ONLY_AUTHORIZED_SELLERS=1
+
+# Mouser Provider API V2:
+# You can get your API key from https://www.mouser.it/api-hub/
+PROVIDER_MOUSER_KEY=
+# Filter search results by RoHS compliance and stock availability:
+# Available options: None | Rohs | InStock | RohsAndInStock
+PROVIDER_MOUSER_SEARCH_OPTION='None'
+# The number of results to get from Mouser while searching (please note that this value is max 50)
+PROVIDER_MOUSER_SEARCH_LIMIT=50
+# It is recommended to leave this set to 'true'. The option is not really good doumented by Mouser:
+# Used when searching for keywords in the language specified when you signed up for Search API.
+PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE='true'
+
+# LCSC Provider:
+# LCSC does not provide an offical API, so this used the API LCSC uses to render their webshop.
+# LCSC did not intended the use of this API and it could break any time, so use it at your own risk.
+
+# We dont require an API key for LCSC, just set this to 1 to enable LCSC support
+PROVIDER_LCSC_ENABLED=0
+# The currency to get prices in (e.g. EUR, USD, etc.)
+PROVIDER_LCSC_CURRENCY=EUR
+
+# Oemsecrets Provider API 3.0.1:
+# You can get your API key from https://www.oemsecrets.com/api
+PROVIDER_OEMSECRETS_KEY=
+# The country you want the output for
+PROVIDER_OEMSECRETS_COUNTRY_CODE=DE
+# Available country code are:
+# AD, AE, AQ, AR, AT, AU, BE, BO, BR, BV, BY, CA, CH, CL, CN, CO, CZ, DE, DK, EC, EE, EH,
+# ES, FI, FK, FO, FR, GB, GE, GF, GG, GI, GL, GR, GS, GY, HK, HM, HR, HU, IE, IM, IN, IS,
+# IT, JM, JP, KP, KR, KZ, LI, LK, LT, LU, MC, MD, ME, MK, MT, NL, NO, NZ, PE, PH, PL, PT,
+# PY, RO, RS, RU, SB, SD, SE, SG, SI, SJ, SK, SM, SO, SR, SY, SZ, TC, TF, TG, TH, TJ, TK,
+# TM, TN, TO, TR, TT, TV, TW, TZ, UA, UG, UM, US, UY, UZ, VA, VE, VG, VI, VN, VU, WF, YE,
+# ZA, ZM, ZW
+#
+# The currency you want the prices to be displayed in
+PROVIDER_OEMSECRETS_CURRENCY=EUR
+# Available currency are:AUD, CAD, CHF, CNY, DKK, EUR, GBP, HKD, ILS, INR, JPY, KRW, NOK,
+# NZD, RUB, SEK, SGD, TWD, USD
+#
+# If PROVIDER_OEMSECRETS_ZERO_PRICE is set to 0, distributors with zero prices
+# will be discarded from the creation of a new part (set to 1 otherwise)
+PROVIDER_OEMSECRETS_ZERO_PRICE=0
+#
+# When PROVIDER_OEMSECRETS_SET_PARAM is set to 1 the parameters for the part are generated
+# from the description transforming unstructured descriptions into structured parameters;
+# each parameter in description should have the form: "...;name1:value1;name2:value2"
+PROVIDER_OEMSECRETS_SET_PARAM=1
+#
+# This environment variable determines the sorting criteria for product results.
+# The sorting process first arranges items based on the provided keyword.
+# Then, if set to 'C', it further sorts by completeness (prioritizing items with the most
+# detailed information). If set to 'M', it further sorts by manufacturer name.
+#If unset or set to any other value, no sorting is performed.
+PROVIDER_OEMSECRETS_SORT_CRITERIA=C
+
+
+# Reichelt provider:
+# Reichelt.com offers no official API, so this info provider webscrapes the website to extract info
+# It could break at any time, use it at your own risk
+# We dont require an API key for Reichelt, just set this to 1 to enable Reichelt support
+PROVIDER_REICHELT_ENABLED=0
+# The country to get prices for
+PROVIDER_REICHELT_COUNTRY=DE
+# The language to get results in (en, de, fr, nl, pl, it, es)
+PROVIDER_REICHELT_LANGUAGE=en
+# Include VAT in prices (set to 1 to include VAT, 0 to exclude VAT)
+PROVIDER_REICHELT_INCLUDE_VAT=1
+# The currency to get prices in (only for countries with countries other than EUR)
+PROVIDER_REICHELT_CURRENCY=EUR
+
+# Pollin provider:
+# Pollin.de offers no official API, so this info provider webscrapes the website to extract info
+# It could break at any time, use it at your own risk
+# We dont require an API key for Pollin, just set this to 1 to enable Pollin support
+PROVIDER_POLLIN_ENABLED=0
+
+##################################################################################
+# EDA integration related settings
+##################################################################################
+
+# This value determines the depth of the category tree, that is visible inside KiCad
+# 0 means that only the top level categories are visible. Set to a value > 0 to show more levels.
+# Set to -1, to show all parts of Part-DB inside a single category in KiCad
+EDA_KICAD_CATEGORY_DEPTH=0
+
+###################################################################################
+# SAML Single sign on-settings
+###################################################################################
+# Set this to 1 to enable SAML single sign on
+# Be also sure to set the correct values for DEFAULT_URI
+SAML_ENABLED=0
+
+# Set to 1, if your Part-DB installation is behind a reverse proxy and you want to use SAML
+SAML_BEHIND_PROXY=0
+
+# A JSON encoded array of role mappings in the form { "saml_role": PARTDB_GROUP_ID, "*": PARTDB_GROUP_ID }
+# The first match is used, so the order is important! Put the group mapping with the most privileges first.
+# Please not to only use single quotes to enclose the JSON string
+SAML_ROLE_MAPPING='{}'
+# A mapping could look like the following
+#SAML_ROLE_MAPPING='{ "*": 2, "admin": 1, "editor": 3}'
+
+# When this is set to 1, the group of SAML users will be updated everytime they login based on their SAML roles
+SAML_UPDATE_GROUP_ON_LOGIN=1
+
+# The entity ID of your SAML IDP (e.g. the realm name of your Keycloak server)
+SAML_IDP_ENTITY_ID="https://idp.changeme.invalid/realms/master"
+# The URL of your SAML IDP SingleSignOnService (e.g. the endpoint of your Keycloak server)
+SAML_IDP_SINGLE_SIGN_ON_SERVICE="https://idp.changeme.invalid/realms/master/protocol/saml"
+# The URL of your SAML IDP SingleLogoutService (e.g. the endpoint of your Keycloak server)
+SAML_IDP_SINGLE_LOGOUT_SERVICE="https://idp.changeme.invalid/realms/master/protocol/saml"
+# The public certificate of the SAML IDP (e.g. the certificate of your Keycloak server)
+SAML_IDP_X509_CERT="MIIC..."
+
+# The entity of your SAML SP, must match the SP entityID configured in your SAML IDP (e.g. Keycloak).
+# This should be a the domain name of your Part-DB installation, followed by "/sp"
+SAML_SP_ENTITY_ID="https://partdb.changeme.invalid/sp"
+
+# The public certificate of the SAML SP
+SAML_SP_X509_CERT="MIIC..."
+# The private key of the SAML SP
+SAML_SP_PRIVATE_KEY="MIIE..."
+
+
######################################################################################
# Other settings
######################################################################################
@@ -79,6 +295,9 @@ DEMO_MODE=0
# In that case all URL contains the index.php front controller in URL
NO_URL_REWRITE_AVAILABLE=0
+# Set to 1, if Part-DB should redirect all HTTP requests to HTTPS. You dont need to configure this, if your webserver already does this.
+REDIRECT_TO_HTTPS=0
+
# If you want to use fixer.io for currency conversion, you have to set this to your API key
FIXER_API_KEY=CHANGEME
@@ -89,9 +308,11 @@ BANNER=""
APP_ENV=prod
APP_SECRET=a03498528f5a5fc089273ec9ae5b2849
+# Set this to zero, if you want to disable the year 2038 bug check on 32-bit systems (it will cause errors with current 32-bit PHP versions)
+DISABLE_YEAR2038_BUG_CHECK=0
# Set the trusted IPs here, when using an reverse proxy
-#TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
+#TRUSTED_PROXIES=127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
#TRUSTED_HOSTS='^(localhost|example\.com)$'
@@ -100,3 +321,7 @@ APP_SECRET=a03498528f5a5fc089273ec9ae5b2849
# postgresql+advisory://db_user:db_password@localhost/db_name
LOCK_DSN=flock
###< symfony/lock ###
+
+###> nelmio/cors-bundle ###
+CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
+###< nelmio/cors-bundle ###
diff --git a/.env.dev b/.env.dev
new file mode 100644
index 00000000..e69de29b
diff --git a/.env.test b/.env.test
index a9b0cccf..3dbece81 100644
--- a/.env.test
+++ b/.env.test
@@ -5,5 +5,9 @@ SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
+DATABASE_URL="sqlite:///%kernel.project_dir%/var/app_test.db"
# Doctrine automatically adds an _test suffix to database name in test env
-DATABASE_URL=mysql://root:@127.0.0.1:3306/part-db
\ No newline at end of file
+#DATABASE_URL=mysql://root:@127.0.0.1:3306/part-db
+
+# Disable update checks, as tests would fail, when github is not reachable
+CHECK_FOR_UPDATES=0
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..6a0a3e1f
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# For sh files, always use LF line endings
+*.sh text eol=lf
\ No newline at end of file
diff --git a/.github/assets/legacy_import/db_jbtronics.sql b/.github/assets/legacy_import/db_jbtronics.sql
new file mode 100644
index 00000000..5237461f
--- /dev/null
+++ b/.github/assets/legacy_import/db_jbtronics.sql
@@ -0,0 +1,752 @@
+-- phpMyAdmin SQL Dump
+-- version 5.1.3
+-- https://www.phpmyadmin.net/
+--
+-- Host: 127.0.0.1
+-- Erstellungszeit: 07. Mai 2023 um 01:58
+-- Server-Version: 10.6.5-MariaDB-log
+-- PHP-Version: 8.1.2
+
+SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
+START TRANSACTION;
+SET time_zone = "+00:00";
+
+
+/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
+/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
+/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
+/*!40101 SET NAMES utf8mb4 */;
+
+--
+-- Datenbank: `partdb_demo`
+--
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `attachements`
+--
+
+CREATE TABLE `attachements` (
+ `id` int(11) NOT NULL,
+ `name` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `class_name` varchar(255) COLLATE utf8mb3_unicode_ci NOT NULL,
+ `element_id` int(11) NOT NULL,
+ `type_id` int(11) NOT NULL,
+ `filename` mediumtext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `show_in_table` tinyint(1) NOT NULL DEFAULT 0,
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+--
+-- Daten für Tabelle `attachements`
+--
+
+INSERT INTO `attachements` (`id`, `name`, `class_name`, `element_id`, `type_id`, `filename`, `show_in_table`, `last_modified`) VALUES
+(1, 'BC547', 'Part', 2, 2, '%BASE%/data/media/bc547.pdf', 1, '0000-00-00 00:00:00');
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `attachement_types`
+--
+
+CREATE TABLE `attachement_types` (
+ `id` int(11) NOT NULL,
+ `name` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `parent_id` int(11) DEFAULT NULL,
+ `comment` text COLLATE utf8mb3_unicode_ci DEFAULT NULL,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+--
+-- Daten für Tabelle `attachement_types`
+--
+
+INSERT INTO `attachement_types` (`id`, `name`, `parent_id`, `comment`, `datetime_added`, `last_modified`) VALUES
+(1, 'Bilder', NULL, NULL, '2017-10-21 17:58:48', '0000-00-00 00:00:00'),
+(2, 'Datenblätter', NULL, NULL, '2017-10-21 17:58:48', '0000-00-00 00:00:00');
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `categories`
+--
+
+CREATE TABLE `categories` (
+ `id` int(11) NOT NULL,
+ `name` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `parent_id` int(11) DEFAULT NULL,
+ `disable_footprints` tinyint(1) NOT NULL DEFAULT 0,
+ `disable_manufacturers` tinyint(1) NOT NULL DEFAULT 0,
+ `disable_autodatasheets` tinyint(1) NOT NULL DEFAULT 0,
+ `disable_properties` tinyint(1) NOT NULL DEFAULT 0,
+ `partname_regex` text COLLATE utf8mb3_unicode_ci NOT NULL,
+ `partname_hint` text COLLATE utf8mb3_unicode_ci NOT NULL,
+ `default_description` text COLLATE utf8mb3_unicode_ci NOT NULL,
+ `default_comment` text COLLATE utf8mb3_unicode_ci NOT NULL,
+ `comment` text COLLATE utf8mb3_unicode_ci DEFAULT NULL,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+--
+-- Daten für Tabelle `categories`
+--
+
+INSERT INTO `categories` (`id`, `name`, `parent_id`, `disable_footprints`, `disable_manufacturers`, `disable_autodatasheets`, `disable_properties`, `partname_regex`, `partname_hint`, `default_description`, `default_comment`, `comment`, `datetime_added`, `last_modified`) VALUES
+(1, 'aktive Bauteile', NULL, 0, 0, 0, 0, '', '', '', '', NULL, '2017-10-21 17:58:49', '0000-00-00 00:00:00');
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `devices`
+--
+
+CREATE TABLE `devices` (
+ `id` int(11) NOT NULL,
+ `name` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `parent_id` int(11) DEFAULT NULL,
+ `order_quantity` int(11) NOT NULL DEFAULT 0,
+ `order_only_missing_parts` tinyint(1) NOT NULL DEFAULT 0,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
+ `comment` text COLLATE utf8mb3_unicode_ci DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+--
+-- Daten für Tabelle `devices`
+--
+
+INSERT INTO `devices` (`id`, `name`, `parent_id`, `order_quantity`, `order_only_missing_parts`, `datetime_added`, `last_modified`, `comment`) VALUES
+(1, 'Test', NULL, 0, 0, '2015-04-16 15:08:56', '0000-00-00 00:00:00', NULL);
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `device_parts`
+--
+
+CREATE TABLE `device_parts` (
+ `id` int(11) NOT NULL,
+ `id_part` int(11) NOT NULL DEFAULT 0,
+ `id_device` int(11) NOT NULL DEFAULT 0,
+ `quantity` int(11) NOT NULL DEFAULT 0,
+ `mountnames` mediumtext COLLATE utf8mb3_unicode_ci NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+--
+-- Daten für Tabelle `device_parts`
+--
+
+INSERT INTO `device_parts` (`id`, `id_part`, `id_device`, `quantity`, `mountnames`) VALUES
+(1, 2, 1, 1, '');
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `footprints`
+--
+
+CREATE TABLE `footprints` (
+ `id` int(11) NOT NULL,
+ `name` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `filename` mediumtext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `filename_3d` mediumtext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `parent_id` int(11) DEFAULT NULL,
+ `comment` text COLLATE utf8mb3_unicode_ci DEFAULT NULL,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+--
+-- Daten für Tabelle `footprints`
+--
+
+INSERT INTO `footprints` (`id`, `name`, `filename`, `filename_3d`, `parent_id`, `comment`, `datetime_added`, `last_modified`) VALUES
+(1, 'LEDs', '%BASE%/img/footprints/Optik/LEDs/Bedrahtet/LED-GELB_3MM.png', '', NULL, NULL, '2017-10-21 17:58:49', '0000-00-00 00:00:00');
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `groups`
+--
+
+CREATE TABLE `groups` (
+ `id` int(11) NOT NULL,
+ `name` varchar(32) NOT NULL,
+ `parent_id` int(11) DEFAULT NULL,
+ `comment` mediumtext DEFAULT NULL,
+ `perms_system` int(11) NOT NULL,
+ `perms_groups` int(11) NOT NULL,
+ `perms_users` int(11) NOT NULL,
+ `perms_self` int(11) NOT NULL,
+ `perms_system_config` int(11) NOT NULL,
+ `perms_system_database` int(11) NOT NULL,
+ `perms_parts` bigint(11) NOT NULL,
+ `perms_parts_name` smallint(6) NOT NULL,
+ `perms_parts_description` smallint(6) NOT NULL,
+ `perms_parts_instock` smallint(6) NOT NULL,
+ `perms_parts_mininstock` smallint(6) NOT NULL,
+ `perms_parts_footprint` smallint(6) NOT NULL,
+ `perms_parts_storelocation` smallint(6) NOT NULL,
+ `perms_parts_manufacturer` smallint(6) NOT NULL,
+ `perms_parts_comment` smallint(6) NOT NULL,
+ `perms_parts_order` smallint(6) NOT NULL,
+ `perms_parts_orderdetails` smallint(6) NOT NULL,
+ `perms_parts_prices` smallint(6) NOT NULL,
+ `perms_parts_attachements` smallint(6) NOT NULL,
+ `perms_devices` int(11) NOT NULL,
+ `perms_devices_parts` int(11) NOT NULL,
+ `perms_storelocations` int(11) NOT NULL,
+ `perms_footprints` int(11) NOT NULL,
+ `perms_categories` int(11) NOT NULL,
+ `perms_suppliers` int(11) NOT NULL,
+ `perms_manufacturers` int(11) NOT NULL,
+ `perms_attachement_types` int(11) NOT NULL,
+ `perms_tools` int(11) NOT NULL,
+ `perms_labels` smallint(6) NOT NULL,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+
+--
+-- Daten für Tabelle `groups`
+--
+
+INSERT INTO `groups` (`id`, `name`, `parent_id`, `comment`, `perms_system`, `perms_groups`, `perms_users`, `perms_self`, `perms_system_config`, `perms_system_database`, `perms_parts`, `perms_parts_name`, `perms_parts_description`, `perms_parts_instock`, `perms_parts_mininstock`, `perms_parts_footprint`, `perms_parts_storelocation`, `perms_parts_manufacturer`, `perms_parts_comment`, `perms_parts_order`, `perms_parts_orderdetails`, `perms_parts_prices`, `perms_parts_attachements`, `perms_devices`, `perms_devices_parts`, `perms_storelocations`, `perms_footprints`, `perms_categories`, `perms_suppliers`, `perms_manufacturers`, `perms_attachement_types`, `perms_tools`, `perms_labels`, `datetime_added`, `last_modified`) VALUES
+(1, 'admins', NULL, 'Users of this group can do everything: Read, Write and Administrative actions.', 21, 1365, 87381, 85, 85, 21, 1431655765, 5, 5, 5, 5, 5, 5, 5, 5, 5, 325, 325, 325, 5461, 325, 5461, 5461, 5461, 5461, 5461, 1365, 1365, 85, '2017-10-21 17:58:46', '2018-10-08 17:27:41'),
+(2, 'readonly', NULL, 'Users of this group can only read informations, use tools, and don\'t have access to administrative tools.', 2, 2730, 43690, 25, 170, 42, 2778027689, 9, 9, 9, 9, 9, 9, 9, 9, 9, 649, 649, 649, 1705, 649, 1705, 1705, 1705, 1705, 1705, 681, 1366, 165, '2017-10-21 17:58:46', '2018-10-08 17:28:35'),
+(3, 'users', NULL, 'Users of this group, can edit part informations, create new ones, etc. but are not allowed to use administrative tools. (But can read current configuration, and see Server status)', 42, 2730, 43689, 89, 105, 41, 1431655765, 5, 5, 5, 5, 5, 5, 5, 5, 5, 325, 325, 325, 5461, 325, 5461, 5461, 5461, 5461, 5461, 1365, 1365, 85, '2017-10-21 17:58:46', '2018-10-08 17:28:17');
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `internal`
+--
+
+CREATE TABLE `internal` (
+ `keyName` char(30) CHARACTER SET ascii NOT NULL,
+ `keyValue` varchar(255) COLLATE utf8mb3_unicode_ci DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+--
+-- Daten für Tabelle `internal`
+--
+
+INSERT INTO `internal` (`keyName`, `keyValue`) VALUES
+('dbVersion', '26');
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `log`
+--
+
+CREATE TABLE `log` (
+ `id` int(11) NOT NULL,
+ `datetime` timestamp NOT NULL DEFAULT current_timestamp(),
+ `id_user` int(11) NOT NULL,
+ `level` tinyint(4) NOT NULL,
+ `type` smallint(6) NOT NULL,
+ `target_id` int(11) NOT NULL,
+ `target_type` smallint(6) NOT NULL,
+ `extra` mediumtext NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `manufacturers`
+--
+
+CREATE TABLE `manufacturers` (
+ `id` int(11) NOT NULL,
+ `name` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `parent_id` int(11) DEFAULT NULL,
+ `address` mediumtext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `phone_number` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `fax_number` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `email_address` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `website` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `auto_product_url` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `comment` text COLLATE utf8mb3_unicode_ci DEFAULT NULL,
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+--
+-- Daten für Tabelle `manufacturers`
+--
+
+INSERT INTO `manufacturers` (`id`, `name`, `parent_id`, `address`, `phone_number`, `fax_number`, `email_address`, `website`, `auto_product_url`, `datetime_added`, `comment`, `last_modified`) VALUES
+(1, 'Atmel', NULL, '', '', '', '', '', '', '2015-03-01 11:27:10', NULL, '0000-00-00 00:00:00');
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `orderdetails`
+--
+
+CREATE TABLE `orderdetails` (
+ `id` int(11) NOT NULL,
+ `part_id` int(11) NOT NULL,
+ `id_supplier` int(11) NOT NULL DEFAULT 0,
+ `supplierpartnr` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `obsolete` tinyint(1) DEFAULT 0,
+ `supplier_product_url` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp()
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `parts`
+--
+
+CREATE TABLE `parts` (
+ `id` int(11) NOT NULL,
+ `id_category` int(11) NOT NULL DEFAULT 0,
+ `name` mediumtext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `description` mediumtext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `instock` int(11) NOT NULL DEFAULT 0,
+ `mininstock` int(11) NOT NULL DEFAULT 0,
+ `comment` mediumtext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `visible` tinyint(1) NOT NULL,
+ `id_footprint` int(11) DEFAULT NULL,
+ `id_storelocation` int(11) DEFAULT NULL,
+ `order_orderdetails_id` int(11) DEFAULT NULL,
+ `order_quantity` int(11) NOT NULL DEFAULT 1,
+ `manual_order` tinyint(1) NOT NULL DEFAULT 0,
+ `id_manufacturer` int(11) DEFAULT NULL,
+ `id_master_picture_attachement` int(11) DEFAULT NULL,
+ `manufacturer_product_url` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
+ `favorite` tinyint(1) NOT NULL DEFAULT 0
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+--
+-- Daten für Tabelle `parts`
+--
+
+INSERT INTO `parts` (`id`, `id_category`, `name`, `description`, `instock`, `mininstock`, `comment`, `visible`, `id_footprint`, `id_storelocation`, `order_orderdetails_id`, `order_quantity`, `manual_order`, `id_manufacturer`, `id_master_picture_attachement`, `manufacturer_product_url`, `datetime_added`, `last_modified`, `favorite`) VALUES
+(2, 1, 'BC547C', 'NPN 45V 0,1A 0,5W', 59, 0, '', 0, 1, 1, NULL, 1, 0, NULL, NULL, '', '2015-03-01 10:40:31', '2016-12-26 10:48:49', 0);
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `pricedetails`
+--
+
+CREATE TABLE `pricedetails` (
+ `id` int(11) NOT NULL,
+ `orderdetails_id` int(11) NOT NULL,
+ `price` decimal(11,5) DEFAULT NULL,
+ `price_related_quantity` int(11) NOT NULL DEFAULT 1,
+ `min_discount_quantity` int(11) NOT NULL DEFAULT 1,
+ `manual_input` tinyint(1) NOT NULL DEFAULT 1,
+ `last_modified` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `storelocations`
+--
+
+CREATE TABLE `storelocations` (
+ `id` int(11) NOT NULL,
+ `name` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `parent_id` int(11) DEFAULT NULL,
+ `is_full` tinyint(1) NOT NULL DEFAULT 0,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `comment` text COLLATE utf8mb3_unicode_ci DEFAULT NULL,
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+--
+-- Daten für Tabelle `storelocations`
+--
+
+INSERT INTO `storelocations` (`id`, `name`, `parent_id`, `is_full`, `datetime_added`, `comment`, `last_modified`) VALUES
+(1, 'Halbleiter I', NULL, 0, '2015-03-01 11:26:37', NULL, '0000-00-00 00:00:00');
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `suppliers`
+--
+
+CREATE TABLE `suppliers` (
+ `id` int(11) NOT NULL,
+ `name` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `parent_id` int(11) DEFAULT NULL,
+ `address` mediumtext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `phone_number` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `fax_number` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `email_address` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `website` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `auto_product_url` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `comment` text COLLATE utf8mb3_unicode_ci DEFAULT NULL,
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+--
+-- Daten für Tabelle `suppliers`
+--
+
+INSERT INTO `suppliers` (`id`, `name`, `parent_id`, `address`, `phone_number`, `fax_number`, `email_address`, `website`, `auto_product_url`, `datetime_added`, `comment`, `last_modified`) VALUES
+(1, 'Test', NULL, '', '', '', '', '', 'Test', '2015-03-01 10:37:23', NULL, '0000-00-00 00:00:00');
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `users`
+--
+
+CREATE TABLE `users` (
+ `id` int(11) NOT NULL,
+ `name` varchar(32) NOT NULL,
+ `password` varchar(255) DEFAULT NULL,
+ `first_name` tinytext DEFAULT NULL,
+ `last_name` tinytext DEFAULT NULL,
+ `department` tinytext DEFAULT NULL,
+ `email` tinytext DEFAULT NULL,
+ `need_pw_change` tinyint(1) NOT NULL DEFAULT 0,
+ `group_id` int(11) DEFAULT NULL,
+ `config_language` tinytext DEFAULT NULL,
+ `config_timezone` tinytext DEFAULT NULL,
+ `config_theme` tinytext DEFAULT NULL,
+ `config_currency` tinytext DEFAULT NULL,
+ `config_image_path` text NOT NULL,
+ `config_instock_comment_w` text NOT NULL,
+ `config_instock_comment_a` text NOT NULL,
+ `perms_system` int(11) NOT NULL,
+ `perms_groups` int(11) NOT NULL,
+ `perms_users` int(11) NOT NULL,
+ `perms_self` int(11) NOT NULL,
+ `perms_system_config` int(11) NOT NULL,
+ `perms_system_database` int(11) NOT NULL,
+ `perms_parts` bigint(11) NOT NULL,
+ `perms_parts_name` smallint(6) NOT NULL,
+ `perms_parts_description` smallint(6) NOT NULL,
+ `perms_parts_instock` smallint(6) NOT NULL,
+ `perms_parts_mininstock` smallint(6) NOT NULL,
+ `perms_parts_footprint` smallint(6) NOT NULL,
+ `perms_parts_storelocation` smallint(6) NOT NULL,
+ `perms_parts_manufacturer` smallint(6) NOT NULL,
+ `perms_parts_comment` smallint(6) NOT NULL,
+ `perms_parts_order` smallint(6) NOT NULL,
+ `perms_parts_orderdetails` smallint(6) NOT NULL,
+ `perms_parts_prices` smallint(6) NOT NULL,
+ `perms_parts_attachements` smallint(6) NOT NULL,
+ `perms_devices` int(11) NOT NULL,
+ `perms_devices_parts` int(11) NOT NULL,
+ `perms_storelocations` int(11) NOT NULL,
+ `perms_footprints` int(11) NOT NULL,
+ `perms_categories` int(11) NOT NULL,
+ `perms_suppliers` int(11) NOT NULL,
+ `perms_manufacturers` int(11) NOT NULL,
+ `perms_attachement_types` int(11) NOT NULL,
+ `perms_tools` int(11) NOT NULL,
+ `perms_labels` smallint(6) NOT NULL,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
+
+--
+-- Daten für Tabelle `users`
+--
+
+INSERT INTO `users` (`id`, `name`, `password`, `first_name`, `last_name`, `department`, `email`, `need_pw_change`, `group_id`, `config_language`, `config_timezone`, `config_theme`, `config_currency`, `config_image_path`, `config_instock_comment_w`, `config_instock_comment_a`, `perms_system`, `perms_groups`, `perms_users`, `perms_self`, `perms_system_config`, `perms_system_database`, `perms_parts`, `perms_parts_name`, `perms_parts_description`, `perms_parts_instock`, `perms_parts_mininstock`, `perms_parts_footprint`, `perms_parts_storelocation`, `perms_parts_manufacturer`, `perms_parts_comment`, `perms_parts_order`, `perms_parts_orderdetails`, `perms_parts_prices`, `perms_parts_attachements`, `perms_devices`, `perms_devices_parts`, `perms_storelocations`, `perms_footprints`, `perms_categories`, `perms_suppliers`, `perms_manufacturers`, `perms_attachement_types`, `perms_tools`, `perms_labels`, `datetime_added`, `last_modified`) VALUES
+(1, 'anonymous', '', '', '', '', '', 0, 2, '', '', '', NULL, '', '', '', 21848, 20480, 0, 0, 0, 0, 0, 21840, 21840, 21840, 21840, 21840, 21840, 21840, 21840, 21840, 21520, 21520, 21520, 20480, 21520, 20480, 20480, 20480, 20480, 20480, 21504, 20480, 0, '2017-10-21 17:58:46', '2018-02-18 12:46:58'),
+(2, 'admin', '$2a$12$j0RKrKlx60bzX1DWMyXwjeaW.pe3bFjAK8ByIGnvjrRnET2JtsFoe', 'Admin', 'Ad', NULL, 'admin@ras.pi', 0, 1, '', '', '', NULL, '', '', '', 21845, 21845, 21845, 21, 85, 21, 349525, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 0, '2017-10-21 17:58:46', '2017-12-23 11:04:48');
+
+--
+-- Indizes der exportierten Tabellen
+--
+
+--
+-- Indizes für die Tabelle `attachements`
+--
+ALTER TABLE `attachements`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `attachements_class_name_k` (`class_name`),
+ ADD KEY `attachements_element_id_k` (`element_id`),
+ ADD KEY `attachements_type_id_fk` (`type_id`);
+
+--
+-- Indizes für die Tabelle `attachement_types`
+--
+ALTER TABLE `attachement_types`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `attachement_types_parent_id_k` (`parent_id`);
+
+--
+-- Indizes für die Tabelle `categories`
+--
+ALTER TABLE `categories`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `categories_parent_id_k` (`parent_id`);
+
+--
+-- Indizes für die Tabelle `devices`
+--
+ALTER TABLE `devices`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `devices_parent_id_k` (`parent_id`);
+
+--
+-- Indizes für die Tabelle `device_parts`
+--
+ALTER TABLE `device_parts`
+ ADD PRIMARY KEY (`id`),
+ ADD UNIQUE KEY `device_parts_combination_uk` (`id_part`,`id_device`),
+ ADD KEY `device_parts_id_part_k` (`id_part`),
+ ADD KEY `device_parts_id_device_k` (`id_device`);
+
+--
+-- Indizes für die Tabelle `footprints`
+--
+ALTER TABLE `footprints`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `footprints_parent_id_k` (`parent_id`);
+
+--
+-- Indizes für die Tabelle `groups`
+--
+ALTER TABLE `groups`
+ ADD PRIMARY KEY (`id`),
+ ADD UNIQUE KEY `name` (`name`);
+
+--
+-- Indizes für die Tabelle `internal`
+--
+ALTER TABLE `internal`
+ ADD UNIQUE KEY `keyName` (`keyName`);
+
+--
+-- Indizes für die Tabelle `log`
+--
+ALTER TABLE `log`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `id_user` (`id_user`);
+
+--
+-- Indizes für die Tabelle `manufacturers`
+--
+ALTER TABLE `manufacturers`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `manufacturers_parent_id_k` (`parent_id`);
+
+--
+-- Indizes für die Tabelle `orderdetails`
+--
+ALTER TABLE `orderdetails`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `orderdetails_part_id_k` (`part_id`),
+ ADD KEY `orderdetails_id_supplier_k` (`id_supplier`);
+
+--
+-- Indizes für die Tabelle `parts`
+--
+ALTER TABLE `parts`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `parts_id_category_k` (`id_category`),
+ ADD KEY `parts_id_footprint_k` (`id_footprint`),
+ ADD KEY `parts_id_storelocation_k` (`id_storelocation`),
+ ADD KEY `parts_order_orderdetails_id_k` (`order_orderdetails_id`),
+ ADD KEY `parts_id_manufacturer_k` (`id_manufacturer`),
+ ADD KEY `favorite` (`favorite`);
+
+--
+-- Indizes für die Tabelle `pricedetails`
+--
+ALTER TABLE `pricedetails`
+ ADD PRIMARY KEY (`id`),
+ ADD UNIQUE KEY `pricedetails_combination_uk` (`orderdetails_id`,`min_discount_quantity`),
+ ADD KEY `pricedetails_orderdetails_id_k` (`orderdetails_id`);
+
+--
+-- Indizes für die Tabelle `storelocations`
+--
+ALTER TABLE `storelocations`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `storelocations_parent_id_k` (`parent_id`);
+
+--
+-- Indizes für die Tabelle `suppliers`
+--
+ALTER TABLE `suppliers`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `suppliers_parent_id_k` (`parent_id`);
+
+--
+-- Indizes für die Tabelle `users`
+--
+ALTER TABLE `users`
+ ADD PRIMARY KEY (`id`),
+ ADD UNIQUE KEY `name` (`name`);
+
+--
+-- AUTO_INCREMENT für exportierte Tabellen
+--
+
+--
+-- AUTO_INCREMENT für Tabelle `attachements`
+--
+ALTER TABLE `attachements`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=201;
+
+--
+-- AUTO_INCREMENT für Tabelle `attachement_types`
+--
+ALTER TABLE `attachement_types`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=3;
+
+--
+-- AUTO_INCREMENT für Tabelle `categories`
+--
+ALTER TABLE `categories`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=123;
+
+--
+-- AUTO_INCREMENT für Tabelle `devices`
+--
+ALTER TABLE `devices`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2;
+
+--
+-- AUTO_INCREMENT für Tabelle `device_parts`
+--
+ALTER TABLE `device_parts`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=12;
+
+--
+-- AUTO_INCREMENT für Tabelle `footprints`
+--
+ALTER TABLE `footprints`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=82;
+
+--
+-- AUTO_INCREMENT für Tabelle `groups`
+--
+ALTER TABLE `groups`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=4;
+
+--
+-- AUTO_INCREMENT für Tabelle `log`
+--
+ALTER TABLE `log`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=218;
+
+--
+-- AUTO_INCREMENT für Tabelle `manufacturers`
+--
+ALTER TABLE `manufacturers`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2;
+
+--
+-- AUTO_INCREMENT für Tabelle `orderdetails`
+--
+ALTER TABLE `orderdetails`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=650;
+
+--
+-- AUTO_INCREMENT für Tabelle `parts`
+--
+ALTER TABLE `parts`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1171;
+
+--
+-- AUTO_INCREMENT für Tabelle `pricedetails`
+--
+ALTER TABLE `pricedetails`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=437;
+
+--
+-- AUTO_INCREMENT für Tabelle `storelocations`
+--
+ALTER TABLE `storelocations`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=340;
+
+--
+-- AUTO_INCREMENT für Tabelle `suppliers`
+--
+ALTER TABLE `suppliers`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=5;
+
+--
+-- AUTO_INCREMENT für Tabelle `users`
+--
+ALTER TABLE `users`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=6;
+
+--
+-- Constraints der exportierten Tabellen
+--
+
+--
+-- Constraints der Tabelle `attachements`
+--
+ALTER TABLE `attachements`
+ ADD CONSTRAINT `attachements_type_id_fk` FOREIGN KEY (`type_id`) REFERENCES `attachement_types` (`id`);
+
+--
+-- Constraints der Tabelle `attachement_types`
+--
+ALTER TABLE `attachement_types`
+ ADD CONSTRAINT `attachement_types_parent_id_fk` FOREIGN KEY (`parent_id`) REFERENCES `attachement_types` (`id`);
+
+--
+-- Constraints der Tabelle `categories`
+--
+ALTER TABLE `categories`
+ ADD CONSTRAINT `categories_parent_id_fk` FOREIGN KEY (`parent_id`) REFERENCES `categories` (`id`);
+
+--
+-- Constraints der Tabelle `devices`
+--
+ALTER TABLE `devices`
+ ADD CONSTRAINT `devices_parent_id_fk` FOREIGN KEY (`parent_id`) REFERENCES `devices` (`id`);
+
+--
+-- Constraints der Tabelle `footprints`
+--
+ALTER TABLE `footprints`
+ ADD CONSTRAINT `footprints_parent_id_fk` FOREIGN KEY (`parent_id`) REFERENCES `footprints` (`id`);
+
+--
+-- Constraints der Tabelle `manufacturers`
+--
+ALTER TABLE `manufacturers`
+ ADD CONSTRAINT `manufacturers_parent_id_fk` FOREIGN KEY (`parent_id`) REFERENCES `manufacturers` (`id`);
+
+--
+-- Constraints der Tabelle `parts`
+--
+ALTER TABLE `parts`
+ ADD CONSTRAINT `parts_id_footprint_fk` FOREIGN KEY (`id_footprint`) REFERENCES `footprints` (`id`),
+ ADD CONSTRAINT `parts_id_manufacturer_fk` FOREIGN KEY (`id_manufacturer`) REFERENCES `manufacturers` (`id`),
+ ADD CONSTRAINT `parts_id_storelocation_fk` FOREIGN KEY (`id_storelocation`) REFERENCES `storelocations` (`id`),
+ ADD CONSTRAINT `parts_order_orderdetails_id_fk` FOREIGN KEY (`order_orderdetails_id`) REFERENCES `orderdetails` (`id`);
+
+--
+-- Constraints der Tabelle `storelocations`
+--
+ALTER TABLE `storelocations`
+ ADD CONSTRAINT `storelocations_parent_id_fk` FOREIGN KEY (`parent_id`) REFERENCES `storelocations` (`id`);
+
+--
+-- Constraints der Tabelle `suppliers`
+--
+ALTER TABLE `suppliers`
+ ADD CONSTRAINT `suppliers_parent_id_fk` FOREIGN KEY (`parent_id`) REFERENCES `suppliers` (`id`);
+COMMIT;
+
+/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
+/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
+/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
diff --git a/.github/assets/legacy_import/db_minimal.sql b/.github/assets/legacy_import/db_minimal.sql
new file mode 100644
index 00000000..7f29d5dc
--- /dev/null
+++ b/.github/assets/legacy_import/db_minimal.sql
@@ -0,0 +1,736 @@
+-- phpMyAdmin SQL Dump
+-- version 5.1.3
+-- https://www.phpmyadmin.net/
+--
+-- Host: 127.0.0.1
+-- Erstellungszeit: 07. Mai 2023 um 01:48
+-- Server-Version: 10.6.5-MariaDB-log
+-- PHP-Version: 8.1.2
+
+SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
+START TRANSACTION;
+SET time_zone = "+00:00";
+
+
+/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
+/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
+/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
+/*!40101 SET NAMES utf8mb4 */;
+
+--
+-- Datenbank: `partdb-legacy`
+--
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `attachements`
+--
+
+CREATE TABLE `attachements` (
+ `id` int(11) NOT NULL,
+ `name` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `class_name` varchar(255) COLLATE utf8mb3_unicode_ci NOT NULL,
+ `element_id` int(11) NOT NULL,
+ `type_id` int(11) NOT NULL,
+ `filename` mediumtext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `show_in_table` tinyint(1) NOT NULL DEFAULT 0,
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `attachement_types`
+--
+
+CREATE TABLE `attachement_types` (
+ `id` int(11) NOT NULL,
+ `name` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `parent_id` int(11) DEFAULT NULL,
+ `comment` text COLLATE utf8mb3_unicode_ci DEFAULT NULL,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+--
+-- Daten für Tabelle `attachement_types`
+--
+
+INSERT INTO `attachement_types` (`id`, `name`, `parent_id`, `comment`, `datetime_added`, `last_modified`) VALUES
+(1, 'Bilder', NULL, NULL, '2023-01-07 18:31:48', '0000-00-00 00:00:00'),
+(2, 'Datenblätter', NULL, NULL, '2023-01-07 18:31:48', '0000-00-00 00:00:00');
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `categories`
+--
+
+CREATE TABLE `categories` (
+ `id` int(11) NOT NULL,
+ `name` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `parent_id` int(11) DEFAULT NULL,
+ `disable_footprints` tinyint(1) NOT NULL DEFAULT 0,
+ `disable_manufacturers` tinyint(1) NOT NULL DEFAULT 0,
+ `disable_autodatasheets` tinyint(1) NOT NULL DEFAULT 0,
+ `disable_properties` tinyint(1) NOT NULL DEFAULT 0,
+ `partname_regex` text COLLATE utf8mb3_unicode_ci NOT NULL DEFAULT '',
+ `partname_hint` text COLLATE utf8mb3_unicode_ci NOT NULL DEFAULT '',
+ `default_description` text COLLATE utf8mb3_unicode_ci NOT NULL DEFAULT '',
+ `default_comment` text COLLATE utf8mb3_unicode_ci NOT NULL DEFAULT '',
+ `comment` text COLLATE utf8mb3_unicode_ci DEFAULT NULL,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+--
+-- Daten für Tabelle `categories`
+--
+
+INSERT INTO `categories` (`id`, `name`, `parent_id`, `disable_footprints`, `disable_manufacturers`, `disable_autodatasheets`, `disable_properties`, `partname_regex`, `partname_hint`, `default_description`, `default_comment`, `comment`, `datetime_added`, `last_modified`) VALUES
+(1, 'Test', NULL, 0, 0, 0, 0, '', '', '', '', '', '2023-01-07 18:32:29', '2023-01-07 18:32:29');
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `devices`
+--
+
+CREATE TABLE `devices` (
+ `id` int(11) NOT NULL,
+ `name` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `parent_id` int(11) DEFAULT NULL,
+ `order_quantity` int(11) NOT NULL DEFAULT 0,
+ `order_only_missing_parts` tinyint(1) NOT NULL DEFAULT 0,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
+ `comment` text COLLATE utf8mb3_unicode_ci DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `device_parts`
+--
+
+CREATE TABLE `device_parts` (
+ `id` int(11) NOT NULL,
+ `id_part` int(11) NOT NULL DEFAULT 0,
+ `id_device` int(11) NOT NULL DEFAULT 0,
+ `quantity` int(11) NOT NULL DEFAULT 0,
+ `mountnames` mediumtext COLLATE utf8mb3_unicode_ci NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `footprints`
+--
+
+CREATE TABLE `footprints` (
+ `id` int(11) NOT NULL,
+ `name` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `filename` mediumtext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `filename_3d` mediumtext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `parent_id` int(11) DEFAULT NULL,
+ `comment` text COLLATE utf8mb3_unicode_ci DEFAULT NULL,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `groups`
+--
+
+CREATE TABLE `groups` (
+ `id` int(11) NOT NULL,
+ `name` varchar(32) NOT NULL,
+ `parent_id` int(11) DEFAULT NULL,
+ `comment` mediumtext DEFAULT NULL,
+ `perms_system` int(11) NOT NULL,
+ `perms_groups` int(11) NOT NULL,
+ `perms_users` int(11) NOT NULL,
+ `perms_self` int(11) NOT NULL,
+ `perms_system_config` int(11) NOT NULL,
+ `perms_system_database` int(11) NOT NULL,
+ `perms_parts` bigint(11) NOT NULL,
+ `perms_parts_name` smallint(6) NOT NULL,
+ `perms_parts_description` smallint(6) NOT NULL,
+ `perms_parts_instock` smallint(6) NOT NULL,
+ `perms_parts_mininstock` smallint(6) NOT NULL,
+ `perms_parts_footprint` smallint(6) NOT NULL,
+ `perms_parts_storelocation` smallint(6) NOT NULL,
+ `perms_parts_manufacturer` smallint(6) NOT NULL,
+ `perms_parts_comment` smallint(6) NOT NULL,
+ `perms_parts_order` smallint(6) NOT NULL,
+ `perms_parts_orderdetails` smallint(6) NOT NULL,
+ `perms_parts_prices` smallint(6) NOT NULL,
+ `perms_parts_attachements` smallint(6) NOT NULL,
+ `perms_devices` int(11) NOT NULL,
+ `perms_devices_parts` int(11) NOT NULL,
+ `perms_storelocations` int(11) NOT NULL,
+ `perms_footprints` int(11) NOT NULL,
+ `perms_categories` int(11) NOT NULL,
+ `perms_suppliers` int(11) NOT NULL,
+ `perms_manufacturers` int(11) NOT NULL,
+ `perms_attachement_types` int(11) NOT NULL,
+ `perms_tools` int(11) NOT NULL,
+ `perms_labels` smallint(6) NOT NULL,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
+) ENGINE=InnoDB DEFAULT CHARSET=latin1;
+
+--
+-- Daten für Tabelle `groups`
+--
+
+INSERT INTO `groups` (`id`, `name`, `parent_id`, `comment`, `perms_system`, `perms_groups`, `perms_users`, `perms_self`, `perms_system_config`, `perms_system_database`, `perms_parts`, `perms_parts_name`, `perms_parts_description`, `perms_parts_instock`, `perms_parts_mininstock`, `perms_parts_footprint`, `perms_parts_storelocation`, `perms_parts_manufacturer`, `perms_parts_comment`, `perms_parts_order`, `perms_parts_orderdetails`, `perms_parts_prices`, `perms_parts_attachements`, `perms_devices`, `perms_devices_parts`, `perms_storelocations`, `perms_footprints`, `perms_categories`, `perms_suppliers`, `perms_manufacturers`, `perms_attachement_types`, `perms_tools`, `perms_labels`, `datetime_added`, `last_modified`) VALUES
+(1, 'admins', NULL, 'Users of this group can do everything: Read, Write and Administrative actions.', 21, 1365, 87381, 85, 85, 21, 1431655765, 5, 5, 5, 5, 5, 5, 5, 5, 5, 325, 325, 325, 5461, 325, 5461, 5461, 5461, 5461, 5461, 1365, 1365, 85, '2023-01-07 18:31:48', '0000-00-00 00:00:00'),
+(2, 'readonly', NULL, 'Users of this group can only read informations, use tools, and don\'t have access to administrative tools.', 42, 2730, 174762, 154, 170, 42, -1516939607, 9, 9, 9, 9, 9, 9, 9, 9, 9, 649, 649, 649, 1705, 649, 1705, 1705, 1705, 1705, 1705, 681, 1366, 165, '2023-01-07 18:31:48', '0000-00-00 00:00:00'),
+(3, 'users', NULL, 'Users of this group, can edit part informations, create new ones, etc. but are not allowed to use administrative tools. (But can read current configuration, and see Server status)', 42, 2730, 109226, 89, 105, 41, 1431655765, 5, 5, 5, 5, 5, 5, 5, 5, 5, 325, 325, 325, 5461, 325, 5461, 5461, 5461, 5461, 5461, 1365, 1365, 85, '2023-01-07 18:31:48', '0000-00-00 00:00:00');
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `internal`
+--
+
+CREATE TABLE `internal` (
+ `keyName` char(30) CHARACTER SET ascii NOT NULL,
+ `keyValue` varchar(255) COLLATE utf8mb3_unicode_ci DEFAULT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+--
+-- Daten für Tabelle `internal`
+--
+
+INSERT INTO `internal` (`keyName`, `keyValue`) VALUES
+('dbVersion', '26');
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `log`
+--
+
+CREATE TABLE `log` (
+ `id` int(11) NOT NULL,
+ `datetime` timestamp NOT NULL DEFAULT current_timestamp(),
+ `id_user` int(11) NOT NULL,
+ `level` tinyint(4) NOT NULL,
+ `type` smallint(6) NOT NULL,
+ `target_id` int(11) NOT NULL,
+ `target_type` smallint(6) NOT NULL,
+ `extra` mediumtext NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=latin1;
+
+--
+-- Daten für Tabelle `log`
+--
+
+INSERT INTO `log` (`id`, `datetime`, `id_user`, `level`, `type`, `target_id`, `target_type`, `extra`) VALUES
+(1, '2023-01-07 18:31:48', 1, 4, 10, 0, 0, '{\"o\":0,\"n\":26,\"s\":true}'),
+(2, '2023-01-07 18:32:13', 2, 6, 1, 2, 1, '{\"i\":\"::\"}'),
+(3, '2023-01-07 18:32:29', 2, 6, 6, 1, 4, '[]'),
+(4, '2023-01-07 18:32:53', 2, 6, 6, 1, 12, '[]'),
+(5, '2023-01-07 18:33:26', 2, 6, 6, 1, 10, '{\"i\":0}');
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `manufacturers`
+--
+
+CREATE TABLE `manufacturers` (
+ `id` int(11) NOT NULL,
+ `name` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `parent_id` int(11) DEFAULT NULL,
+ `address` mediumtext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `phone_number` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `fax_number` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `email_address` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `website` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `auto_product_url` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `comment` text COLLATE utf8mb3_unicode_ci DEFAULT NULL,
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `orderdetails`
+--
+
+CREATE TABLE `orderdetails` (
+ `id` int(11) NOT NULL,
+ `part_id` int(11) NOT NULL,
+ `id_supplier` int(11) NOT NULL DEFAULT 0,
+ `supplierpartnr` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `obsolete` tinyint(1) DEFAULT 0,
+ `supplier_product_url` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp()
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+--
+-- Daten für Tabelle `orderdetails`
+--
+
+INSERT INTO `orderdetails` (`id`, `part_id`, `id_supplier`, `supplierpartnr`, `obsolete`, `supplier_product_url`, `datetime_added`) VALUES
+(1, 1, 1, 'BC547', 0, '', '2023-01-07 18:45:59'),
+(2, 1, 1, 'Test', 0, '', '2023-01-07 18:46:09');
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `parts`
+--
+
+CREATE TABLE `parts` (
+ `id` int(11) NOT NULL,
+ `id_category` int(11) NOT NULL DEFAULT 0,
+ `name` mediumtext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `description` mediumtext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `instock` int(11) NOT NULL DEFAULT 0,
+ `mininstock` int(11) NOT NULL DEFAULT 0,
+ `comment` mediumtext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `visible` tinyint(1) NOT NULL,
+ `id_footprint` int(11) DEFAULT NULL,
+ `id_storelocation` int(11) DEFAULT NULL,
+ `order_orderdetails_id` int(11) DEFAULT NULL,
+ `order_quantity` int(11) NOT NULL DEFAULT 1,
+ `manual_order` tinyint(1) NOT NULL DEFAULT 0,
+ `id_manufacturer` int(11) DEFAULT NULL,
+ `id_master_picture_attachement` int(11) DEFAULT NULL,
+ `manufacturer_product_url` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
+ `favorite` tinyint(1) NOT NULL DEFAULT 0
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+--
+-- Daten für Tabelle `parts`
+--
+
+INSERT INTO `parts` (`id`, `id_category`, `name`, `description`, `instock`, `mininstock`, `comment`, `visible`, `id_footprint`, `id_storelocation`, `order_orderdetails_id`, `order_quantity`, `manual_order`, `id_manufacturer`, `id_master_picture_attachement`, `manufacturer_product_url`, `datetime_added`, `last_modified`, `favorite`) VALUES
+(1, 1, 'BC547', '', 0, 0, '', 0, NULL, NULL, NULL, 1, 0, NULL, NULL, '', '2023-01-07 18:33:26', '2023-01-07 18:33:26', 0);
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `pricedetails`
+--
+
+CREATE TABLE `pricedetails` (
+ `id` int(11) NOT NULL,
+ `orderdetails_id` int(11) NOT NULL,
+ `price` decimal(11,5) DEFAULT NULL,
+ `price_related_quantity` int(11) NOT NULL DEFAULT 1,
+ `min_discount_quantity` int(11) NOT NULL DEFAULT 1,
+ `manual_input` tinyint(1) NOT NULL DEFAULT 1,
+ `last_modified` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+--
+-- Daten für Tabelle `pricedetails`
+--
+
+INSERT INTO `pricedetails` (`id`, `orderdetails_id`, `price`, `price_related_quantity`, `min_discount_quantity`, `manual_input`, `last_modified`) VALUES
+(1, 2, '3.55000', 1, 1, 1, '2023-01-07 18:46:19');
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `storelocations`
+--
+
+CREATE TABLE `storelocations` (
+ `id` int(11) NOT NULL,
+ `name` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `parent_id` int(11) DEFAULT NULL,
+ `is_full` tinyint(1) NOT NULL DEFAULT 0,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `comment` text COLLATE utf8mb3_unicode_ci DEFAULT NULL,
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `suppliers`
+--
+
+CREATE TABLE `suppliers` (
+ `id` int(11) NOT NULL,
+ `name` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `parent_id` int(11) DEFAULT NULL,
+ `address` mediumtext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `phone_number` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `fax_number` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `email_address` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `website` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `auto_product_url` tinytext COLLATE utf8mb3_unicode_ci NOT NULL,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `comment` text COLLATE utf8mb3_unicode_ci DEFAULT NULL,
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci;
+
+--
+-- Daten für Tabelle `suppliers`
+--
+
+INSERT INTO `suppliers` (`id`, `name`, `parent_id`, `address`, `phone_number`, `fax_number`, `email_address`, `website`, `auto_product_url`, `datetime_added`, `comment`, `last_modified`) VALUES
+(1, 'Reichelt', NULL, '', '', '', '', '', '', '2023-01-07 18:32:53', '', '2023-01-07 18:32:53');
+
+-- --------------------------------------------------------
+
+--
+-- Tabellenstruktur für Tabelle `users`
+--
+
+CREATE TABLE `users` (
+ `id` int(11) NOT NULL,
+ `name` varchar(32) NOT NULL,
+ `password` varchar(255) DEFAULT NULL,
+ `first_name` tinytext DEFAULT NULL,
+ `last_name` tinytext DEFAULT NULL,
+ `department` tinytext DEFAULT NULL,
+ `email` tinytext DEFAULT NULL,
+ `need_pw_change` tinyint(1) NOT NULL DEFAULT 0,
+ `group_id` int(11) DEFAULT NULL,
+ `config_language` tinytext DEFAULT NULL,
+ `config_timezone` tinytext DEFAULT NULL,
+ `config_theme` tinytext DEFAULT NULL,
+ `config_currency` tinytext DEFAULT NULL,
+ `config_image_path` text NOT NULL,
+ `config_instock_comment_w` text NOT NULL,
+ `config_instock_comment_a` text NOT NULL,
+ `perms_system` int(11) NOT NULL,
+ `perms_groups` int(11) NOT NULL,
+ `perms_users` int(11) NOT NULL,
+ `perms_self` int(11) NOT NULL,
+ `perms_system_config` int(11) NOT NULL,
+ `perms_system_database` int(11) NOT NULL,
+ `perms_parts` bigint(11) NOT NULL,
+ `perms_parts_name` smallint(6) NOT NULL,
+ `perms_parts_description` smallint(6) NOT NULL,
+ `perms_parts_instock` smallint(6) NOT NULL,
+ `perms_parts_mininstock` smallint(6) NOT NULL,
+ `perms_parts_footprint` smallint(6) NOT NULL,
+ `perms_parts_storelocation` smallint(6) NOT NULL,
+ `perms_parts_manufacturer` smallint(6) NOT NULL,
+ `perms_parts_comment` smallint(6) NOT NULL,
+ `perms_parts_order` smallint(6) NOT NULL,
+ `perms_parts_orderdetails` smallint(6) NOT NULL,
+ `perms_parts_prices` smallint(6) NOT NULL,
+ `perms_parts_attachements` smallint(6) NOT NULL,
+ `perms_devices` int(11) NOT NULL,
+ `perms_devices_parts` int(11) NOT NULL,
+ `perms_storelocations` int(11) NOT NULL,
+ `perms_footprints` int(11) NOT NULL,
+ `perms_categories` int(11) NOT NULL,
+ `perms_suppliers` int(11) NOT NULL,
+ `perms_manufacturers` int(11) NOT NULL,
+ `perms_attachement_types` int(11) NOT NULL,
+ `perms_tools` int(11) NOT NULL,
+ `perms_labels` smallint(6) NOT NULL,
+ `datetime_added` timestamp NOT NULL DEFAULT current_timestamp(),
+ `last_modified` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
+) ENGINE=InnoDB DEFAULT CHARSET=latin1;
+
+--
+-- Daten für Tabelle `users`
+--
+
+INSERT INTO `users` (`id`, `name`, `password`, `first_name`, `last_name`, `department`, `email`, `need_pw_change`, `group_id`, `config_language`, `config_timezone`, `config_theme`, `config_currency`, `config_image_path`, `config_instock_comment_w`, `config_instock_comment_a`, `perms_system`, `perms_groups`, `perms_users`, `perms_self`, `perms_system_config`, `perms_system_database`, `perms_parts`, `perms_parts_name`, `perms_parts_description`, `perms_parts_instock`, `perms_parts_mininstock`, `perms_parts_footprint`, `perms_parts_storelocation`, `perms_parts_manufacturer`, `perms_parts_comment`, `perms_parts_order`, `perms_parts_orderdetails`, `perms_parts_prices`, `perms_parts_attachements`, `perms_devices`, `perms_devices_parts`, `perms_storelocations`, `perms_footprints`, `perms_categories`, `perms_suppliers`, `perms_manufacturers`, `perms_attachement_types`, `perms_tools`, `perms_labels`, `datetime_added`, `last_modified`) VALUES
+(1, 'anonymous', '', '', '', '', '', 0, 2, NULL, NULL, NULL, NULL, '', '', '', 21844, 20480, 0, 0, 0, 0, 0, 21840, 21840, 21840, 21840, 21840, 21840, 21840, 21840, 21840, 21520, 21520, 21520, 20480, 21520, 20480, 20480, 20480, 20480, 20480, 21504, 20480, 0, '2023-01-07 18:31:48', '0000-00-00 00:00:00'),
+(2, 'admin', '$2a$12$j0RKrKlx60bzX1DWMyXwjeaW.pe3bFjAK8ByIGnvjrRnET2JtsFoe$2a$12$j0RKrKlx60bzX1DWMyXwjeaW.pe3bFjAK8ByIGnvjrRnET2JtsFoe', '', '', '', '', 1, 1, NULL, NULL, NULL, NULL, '', '', '', 21845, 21845, 21845, 21, 85, 21, 349525, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 21845, 0, '2023-01-07 18:31:48', '0000-00-00 00:00:00');
+
+--
+-- Indizes der exportierten Tabellen
+--
+
+--
+-- Indizes für die Tabelle `attachements`
+--
+ALTER TABLE `attachements`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `attachements_class_name_k` (`class_name`),
+ ADD KEY `attachements_element_id_k` (`element_id`),
+ ADD KEY `attachements_type_id_fk` (`type_id`);
+
+--
+-- Indizes für die Tabelle `attachement_types`
+--
+ALTER TABLE `attachement_types`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `attachement_types_parent_id_k` (`parent_id`);
+
+--
+-- Indizes für die Tabelle `categories`
+--
+ALTER TABLE `categories`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `categories_parent_id_k` (`parent_id`);
+
+--
+-- Indizes für die Tabelle `devices`
+--
+ALTER TABLE `devices`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `devices_parent_id_k` (`parent_id`);
+
+--
+-- Indizes für die Tabelle `device_parts`
+--
+ALTER TABLE `device_parts`
+ ADD PRIMARY KEY (`id`),
+ ADD UNIQUE KEY `device_parts_combination_uk` (`id_part`,`id_device`),
+ ADD KEY `device_parts_id_part_k` (`id_part`),
+ ADD KEY `device_parts_id_device_k` (`id_device`);
+
+--
+-- Indizes für die Tabelle `footprints`
+--
+ALTER TABLE `footprints`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `footprints_parent_id_k` (`parent_id`);
+
+--
+-- Indizes für die Tabelle `groups`
+--
+ALTER TABLE `groups`
+ ADD PRIMARY KEY (`id`),
+ ADD UNIQUE KEY `name` (`name`);
+
+--
+-- Indizes für die Tabelle `internal`
+--
+ALTER TABLE `internal`
+ ADD UNIQUE KEY `keyName` (`keyName`);
+
+--
+-- Indizes für die Tabelle `log`
+--
+ALTER TABLE `log`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `id_user` (`id_user`);
+
+--
+-- Indizes für die Tabelle `manufacturers`
+--
+ALTER TABLE `manufacturers`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `manufacturers_parent_id_k` (`parent_id`);
+
+--
+-- Indizes für die Tabelle `orderdetails`
+--
+ALTER TABLE `orderdetails`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `orderdetails_part_id_k` (`part_id`),
+ ADD KEY `orderdetails_id_supplier_k` (`id_supplier`);
+
+--
+-- Indizes für die Tabelle `parts`
+--
+ALTER TABLE `parts`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `parts_id_category_k` (`id_category`),
+ ADD KEY `parts_id_footprint_k` (`id_footprint`),
+ ADD KEY `parts_id_storelocation_k` (`id_storelocation`),
+ ADD KEY `parts_order_orderdetails_id_k` (`order_orderdetails_id`),
+ ADD KEY `parts_id_manufacturer_k` (`id_manufacturer`),
+ ADD KEY `favorite` (`favorite`);
+
+--
+-- Indizes für die Tabelle `pricedetails`
+--
+ALTER TABLE `pricedetails`
+ ADD PRIMARY KEY (`id`),
+ ADD UNIQUE KEY `pricedetails_combination_uk` (`orderdetails_id`,`min_discount_quantity`),
+ ADD KEY `pricedetails_orderdetails_id_k` (`orderdetails_id`);
+
+--
+-- Indizes für die Tabelle `storelocations`
+--
+ALTER TABLE `storelocations`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `storelocations_parent_id_k` (`parent_id`);
+
+--
+-- Indizes für die Tabelle `suppliers`
+--
+ALTER TABLE `suppliers`
+ ADD PRIMARY KEY (`id`),
+ ADD KEY `suppliers_parent_id_k` (`parent_id`);
+
+--
+-- Indizes für die Tabelle `users`
+--
+ALTER TABLE `users`
+ ADD PRIMARY KEY (`id`),
+ ADD UNIQUE KEY `name` (`name`);
+
+--
+-- AUTO_INCREMENT für exportierte Tabellen
+--
+
+--
+-- AUTO_INCREMENT für Tabelle `attachements`
+--
+ALTER TABLE `attachements`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT für Tabelle `attachement_types`
+--
+ALTER TABLE `attachement_types`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=3;
+
+--
+-- AUTO_INCREMENT für Tabelle `categories`
+--
+ALTER TABLE `categories`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2;
+
+--
+-- AUTO_INCREMENT für Tabelle `devices`
+--
+ALTER TABLE `devices`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT für Tabelle `device_parts`
+--
+ALTER TABLE `device_parts`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT für Tabelle `footprints`
+--
+ALTER TABLE `footprints`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT für Tabelle `groups`
+--
+ALTER TABLE `groups`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=4;
+
+--
+-- AUTO_INCREMENT für Tabelle `log`
+--
+ALTER TABLE `log`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=6;
+
+--
+-- AUTO_INCREMENT für Tabelle `manufacturers`
+--
+ALTER TABLE `manufacturers`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT für Tabelle `orderdetails`
+--
+ALTER TABLE `orderdetails`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=3;
+
+--
+-- AUTO_INCREMENT für Tabelle `parts`
+--
+ALTER TABLE `parts`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2;
+
+--
+-- AUTO_INCREMENT für Tabelle `pricedetails`
+--
+ALTER TABLE `pricedetails`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2;
+
+--
+-- AUTO_INCREMENT für Tabelle `storelocations`
+--
+ALTER TABLE `storelocations`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
+
+--
+-- AUTO_INCREMENT für Tabelle `suppliers`
+--
+ALTER TABLE `suppliers`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2;
+
+--
+-- AUTO_INCREMENT für Tabelle `users`
+--
+ALTER TABLE `users`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=3;
+
+--
+-- Constraints der exportierten Tabellen
+--
+
+--
+-- Constraints der Tabelle `attachements`
+--
+ALTER TABLE `attachements`
+ ADD CONSTRAINT `attachements_type_id_fk` FOREIGN KEY (`type_id`) REFERENCES `attachement_types` (`id`);
+
+--
+-- Constraints der Tabelle `attachement_types`
+--
+ALTER TABLE `attachement_types`
+ ADD CONSTRAINT `attachement_types_parent_id_fk` FOREIGN KEY (`parent_id`) REFERENCES `attachement_types` (`id`);
+
+--
+-- Constraints der Tabelle `categories`
+--
+ALTER TABLE `categories`
+ ADD CONSTRAINT `categories_parent_id_fk` FOREIGN KEY (`parent_id`) REFERENCES `categories` (`id`);
+
+--
+-- Constraints der Tabelle `devices`
+--
+ALTER TABLE `devices`
+ ADD CONSTRAINT `devices_parent_id_fk` FOREIGN KEY (`parent_id`) REFERENCES `devices` (`id`);
+
+--
+-- Constraints der Tabelle `footprints`
+--
+ALTER TABLE `footprints`
+ ADD CONSTRAINT `footprints_parent_id_fk` FOREIGN KEY (`parent_id`) REFERENCES `footprints` (`id`);
+
+--
+-- Constraints der Tabelle `manufacturers`
+--
+ALTER TABLE `manufacturers`
+ ADD CONSTRAINT `manufacturers_parent_id_fk` FOREIGN KEY (`parent_id`) REFERENCES `manufacturers` (`id`);
+
+--
+-- Constraints der Tabelle `parts`
+--
+ALTER TABLE `parts`
+ ADD CONSTRAINT `parts_id_footprint_fk` FOREIGN KEY (`id_footprint`) REFERENCES `footprints` (`id`),
+ ADD CONSTRAINT `parts_id_manufacturer_fk` FOREIGN KEY (`id_manufacturer`) REFERENCES `manufacturers` (`id`),
+ ADD CONSTRAINT `parts_id_storelocation_fk` FOREIGN KEY (`id_storelocation`) REFERENCES `storelocations` (`id`),
+ ADD CONSTRAINT `parts_order_orderdetails_id_fk` FOREIGN KEY (`order_orderdetails_id`) REFERENCES `orderdetails` (`id`);
+
+--
+-- Constraints der Tabelle `storelocations`
+--
+ALTER TABLE `storelocations`
+ ADD CONSTRAINT `storelocations_parent_id_fk` FOREIGN KEY (`parent_id`) REFERENCES `storelocations` (`id`);
+
+--
+-- Constraints der Tabelle `suppliers`
+--
+ALTER TABLE `suppliers`
+ ADD CONSTRAINT `suppliers_parent_id_fk` FOREIGN KEY (`parent_id`) REFERENCES `suppliers` (`id`);
+COMMIT;
+
+/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
+/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
+/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
diff --git a/.github/assets/legacy_import/test_legacy_import.sh b/.github/assets/legacy_import/test_legacy_import.sh
new file mode 100644
index 00000000..54637c4c
--- /dev/null
+++ b/.github/assets/legacy_import/test_legacy_import.sh
@@ -0,0 +1,54 @@
+#
+# This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+#
+# Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+#!/bin/bash
+
+# This script is used to test the legacy import of Part-DB
+
+SQL_FILES_TO_TEST=("db_minimal.sql" "db_jbtronics.sql")
+
+DB_NAME="legacy_db_test"
+DB_USER="root"
+DB_PASSWORD="root"
+
+# Iterate over all given SQL files and import them into the mysql database with the given name, drop the database if it already exists before
+for SQL_FILE in "${SQL_FILES_TO_TEST[@]}"
+do
+ echo "Testing for $SQL_FILE"
+ mysql -u $DB_USER --password=$DB_PASSWORD -e "DROP DATABASE IF EXISTS $DB_NAME; CREATE DATABASE $DB_NAME;"
+ # If the last command failed, exit the script
+ if [ $? -ne 0 ]; then
+ echo "Failed to create database $DB_NAME"
+ exit 1
+ fi
+ # Import the SQL file into the database. The file pathes are relative to the current script location
+ mysql -u $DB_USER --password=$DB_PASSWORD $DB_NAME < .github/assets/legacy_import/$SQL_FILE
+ # If the last command failed, exit the script
+ if [ $? -ne 0 ]; then
+ echo "Failed to import $SQL_FILE into database $DB_NAME"
+ exit 1
+ fi
+ # Run doctrine migrations, this will migrate the database to the current version. This process should not fail
+ php bin/console doctrine:migrations:migrate -n
+ # If the last command failed, exit the script
+ if [ $? -ne 0 ]; then
+ echo "Failed to migrate database $DB_NAME"
+ exit 1
+ fi
+done
\ No newline at end of file
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..90e05c40
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+ - package-ecosystem: "github-actions" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/assets_artifact_build.yml b/.github/workflows/assets_artifact_build.yml
index 0c3c1568..0bbfe432 100644
--- a/.github/workflows/assets_artifact_build.yml
+++ b/.github/workflows/assets_artifact_build.yml
@@ -19,14 +19,22 @@ jobs:
APP_ENV: prod
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.2'
+ coverage: none
+ ini-values: xdebug.max_nesting_level=1000
+ extensions: mbstring, intl, gd, xsl, gmp, bcmath, :php-psr
- name: Get Composer Cache Directory
id: composer-cache
run: |
echo "::set-output name=dir::$(composer config cache-files-dir)"
- - uses: actions/cache@v3
+ - uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
@@ -40,7 +48,7 @@ jobs:
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- - uses: actions/cache@v3
+ - uses: actions/cache@v4
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@@ -49,7 +57,7 @@ jobs:
${{ runner.os }}-yarn-
- name: Setup node
- uses: actions/setup-node@v3
+ uses: actions/setup-node@v4
with:
node-version: '18'
@@ -69,13 +77,13 @@ jobs:
run: zip -r /tmp/partdb_assets.zip public/build/ vendor/
- name: Upload assets artifact
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: Only dependencies and built assets
path: /tmp/partdb_assets.zip
- name: Upload full artifact
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: Full Part-DB including dependencies and built assets
path: /tmp/partdb_with_assets.zip
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
deleted file mode 100644
index 757bd044..00000000
--- a/.github/workflows/codeql-analysis.yml
+++ /dev/null
@@ -1,54 +0,0 @@
-name: "CodeQL"
-
-on:
- push:
- branches: [master, ]
- pull_request:
- # The branches below must be a subset of the branches above
- branches: [master]
- schedule:
- - cron: '0 14 * * 3'
-
-jobs:
- analyse:
- name: Analyse
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v2
- with:
- # We must fetch at least the immediate parents so that if this is
- # a pull request then we can checkout the head.
- fetch-depth: 2
-
- # If this run was triggered by a pull request event, then checkout
- # the head of the pull request instead of the merge commit.
- - run: git checkout HEAD^2
- if: ${{ github.event_name == 'pull_request' }}
-
- # Initializes the CodeQL tools for scanning.
- - name: Initialize CodeQL
- uses: github/codeql-action/init@v1
- # Override language selection by uncommenting this and choosing your languages
- # with:
- # languages: go, javascript, csharp, python, cpp, java
-
- # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
- # If this step fails, then you should remove it and run the build manually (see below)
- - name: Autobuild
- uses: github/codeql-action/autobuild@v1
-
- # ℹ️ Command-line programs to run using the OS shell.
- # 📚 https://git.io/JvXDl
-
- # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
- # and modify them (or add more) to build your code if your project
- # uses a compiled language
-
- #- run: |
- # make bootstrap
- # make release
-
- - name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v1
diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml
index 4f9b3d44..64287d83 100644
--- a/.github/workflows/docker_build.yml
+++ b/.github/workflows/docker_build.yml
@@ -10,9 +10,6 @@ on:
tags:
- 'v*.*.*'
- 'v*.*.*-**'
- pull_request:
- branches:
- - 'master'
jobs:
docker:
@@ -20,11 +17,11 @@ jobs:
steps:
-
name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
-
name: Docker meta
id: docker_meta
- uses: docker/metadata-action@v4
+ uses: docker/metadata-action@v5
with:
# list of Docker images to use as base name for tags
images: |
@@ -52,23 +49,23 @@ jobs:
-
name: Set up QEMU
- uses: docker/setup-qemu-action@v2
+ uses: docker/setup-qemu-action@v3
with:
platforms: 'arm64,arm'
-
name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v2
+ uses: docker/setup-buildx-action@v3
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
- uses: docker/login-action@v2
+ uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
- uses: docker/build-push-action@v4
+ uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
diff --git a/.github/workflows/docker_frankenphp.yml b/.github/workflows/docker_frankenphp.yml
new file mode 100644
index 00000000..d8cd0695
--- /dev/null
+++ b/.github/workflows/docker_frankenphp.yml
@@ -0,0 +1,77 @@
+name: Docker Image Build (FrankenPHP)
+
+on:
+ #schedule:
+ # - cron: '0 10 * * *' # everyday at 10am
+ push:
+ branches:
+ - '**'
+ - '!l10n_**'
+ tags:
+ - 'v*.*.*'
+ - 'v*.*.*-**'
+
+jobs:
+ docker:
+ runs-on: ubuntu-latest
+ steps:
+ -
+ name: Checkout
+ uses: actions/checkout@v4
+ -
+ name: Docker meta
+ id: docker_meta
+ uses: docker/metadata-action@v5
+ with:
+ # list of Docker images to use as base name for tags
+ images: |
+ partdborg/part-db
+ # Mark the image build from master as latest (as we dont have really releases yet)
+ tags: |
+ type=edge,branch=master
+ type=ref,event=branch,
+ type=ref,event=tag,
+ type=schedule
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=semver,pattern={{major}}
+ type=ref,event=branch
+ type=ref,event=pr
+ labels: |
+ org.opencontainers.image.source=${{ github.event.repository.clone_url }}
+ org.opencontainers.image.revision=${{ github.sha }}
+ org.opencontainers.image.title=Part-DB
+ org.opencontainers.image.description=Part-DB is a web application for managing electronic components and your inventory.
+ org.opencontainers.image.url=https://github.com/Part-DB/Part-DB-server
+ org.opencontainers.image.source=https://github.com/Part-DB/Part-DB-server
+ org.opencontainers.image.authors=Jan Böhmer
+ org.opencontainers.licenses=AGPL-3.0-or-later
+
+ -
+ name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+ with:
+ platforms: 'arm64,arm'
+ -
+ name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+ -
+ name: Login to DockerHub
+ if: github.event_name != 'pull_request'
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ -
+ name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: Dockerfile-frankenphp
+ platforms: linux/amd64,linux/arm64,linux/arm/v7
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: ${{ steps.docker_meta.outputs.tags }}
+ labels: ${{ steps.docker_meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
\ No newline at end of file
diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml
index 78df6a1d..20150b28 100644
--- a/.github/workflows/static_analysis.yml
+++ b/.github/workflows/static_analysis.yml
@@ -16,14 +16,22 @@ jobs:
runs-on: ubuntu-22.04
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.2'
+ coverage: none
+ ini-values: xdebug.max_nesting_level=1000
+ extensions: mbstring, intl, gd, xsl, gmp, bcmath, :php-psr
- name: Get Composer Cache Directory
id: composer-cache
run: |
echo "::set-output name=dir::$(composer config cache-files-dir)"
- - uses: actions/cache@v3
+ - uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
@@ -38,18 +46,20 @@ jobs:
- name: Lint twig templates
run: ./bin/console lint:twig templates --env=prod
-
- - name: Lint translations
- run: ./bin/console lint:xliff translations
+
+ # This causes problems with emtpy language files
+ #- name: Lint translations
+ # run: ./bin/console lint:xliff translations
- name: Check dependencies for security
- uses: symfonycorp/security-checker-action@v3
+ uses: symfonycorp/security-checker-action@v5
- name: Check doctrine mapping
run: ./bin/console doctrine:schema:validate --skip-sync -vvv --no-interaction
-
+
+ # Use the -d option to raise the max nesting level
- name: Generate dev container
- run: ./bin/console cache:clear --env dev
+ run: php -d xdebug.max_nesting_level=1000 ./bin/console cache:clear --env dev
- name: Run PHPstan
run: composer phpstan
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 5cb4f454..8e6ea54c 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -13,13 +13,13 @@ on:
jobs:
phpunit:
name: PHPUnit and coverage Test (PHP ${{ matrix.php-versions }}, ${{ matrix.db-type }})
- # Ubuntu 20.04 ships MySQL 8.0 which causes problems with login, so we just use ubuntu 18.04 for now...
runs-on: ubuntu-22.04
strategy:
+ fail-fast: false
matrix:
- php-versions: [ '7.4', '8.0', '8.1', '8.2' ]
- db-type: [ 'mysql', 'sqlite' ]
+ php-versions: [ '8.1', '8.2', '8.3', '8.4' ]
+ db-type: [ 'mysql', 'sqlite', 'postgres' ]
env:
# Note that we set DATABASE URL later based on our db-type matrix value
@@ -27,30 +27,45 @@ jobs:
SYMFONY_DEPRECATIONS_HELPER: disabled
PHP_VERSION: ${{ matrix.php-versions }}
DB_TYPE: ${{ matrix.db-type }}
+ CHECK_FOR_UPDATES: false # Disable update checks for tests
steps:
- name: Set Database env for MySQL
- run: echo "DATABASE_URL=mysql://root:root@127.0.0.1:3306/test" >> $GITHUB_ENV
+ run: echo "DATABASE_URL=mysql://root:root@127.0.0.1:3306/partdb?serverVersion=8.0.35" >> $GITHUB_ENV
if: matrix.db-type == 'mysql'
- name: Set Database env for SQLite
run: echo "DATABASE_URL="sqlite:///%kernel.project_dir%/var/app_test.db"" >> $GITHUB_ENV
if: matrix.db-type == 'sqlite'
+ - name: Set Database env for PostgreSQL
+ run: echo "DATABASE_URL=postgresql://postgres:postgres @127.0.0.1:5432/partdb?serverVersion=14&charset=utf8" >> $GITHUB_ENV
+ if: matrix.db-type == 'postgres'
+
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-versions }}
coverage: pcov
- extensions: mbstring, intl, gd, xsl, gmp, bcmath
+ ini-values: xdebug.max_nesting_level=1000
+ extensions: mbstring, intl, gd, xsl, gmp, bcmath, :php-psr
- name: Start MySQL
run: sudo systemctl start mysql.service
+ if: matrix.db-type == 'mysql'
- #- name: Setup MySQL
+ # Replace the scram-sha-256 with trust for host connections, to avoid password authentication
+ - name: Start PostgreSQL
+ run: |
+ sudo sed -i 's/^\(host.*all.*all.*\)scram-sha-256/\1trust/' /etc/postgresql/14/main/pg_hba.conf
+ sudo systemctl start postgresql.service
+ sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'postgres';"
+ if: matrix.db-type == 'postgres'
+
+ #- name: Setup MySQL
# uses: mirromutth/mysql-action@v1.1
# with:
# mysql version: 5.7
@@ -63,7 +78,7 @@ jobs:
id: composer-cache
run: |
echo "::set-output name=dir::$(composer config cache-files-dir)"
- - uses: actions/cache@v1
+ - uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
@@ -74,7 +89,7 @@ jobs:
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
- - uses: actions/cache@v3
+ - uses: actions/cache@v4
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@@ -86,7 +101,7 @@ jobs:
run: composer install --prefer-dist --no-progress
- name: Setup node
- uses: actions/setup-node@v3
+ uses: actions/setup-node@v4
with:
node-version: '18'
@@ -98,26 +113,24 @@ jobs:
- name: Create DB
run: php bin/console --env test doctrine:database:create --if-not-exists -n
- if: matrix.db-type == 'mysql'
-
- # Checkinf for existance is not supported for sqlite, so do it without it
- - name: Create DB
- run: php bin/console --env test doctrine:database:create -n
- if: matrix.db-type == 'sqlite'
+ if: matrix.db-type == 'mysql' || matrix.db-type == 'postgres'
- name: Do migrations
run: php bin/console --env test doctrine:migrations:migrate -n
-
+
+ # Use our own custom fixtures loading command to circumvent some problems with reset the autoincrement values
- name: Load fixtures
- run: php bin/console --env test doctrine:fixtures:load -n --purger reset_autoincrement_purger
+ run: php bin/console --env test partdb:fixtures:load -n
- name: Run PHPunit and generate coverage
run: ./bin/phpunit --coverage-clover=coverage.xml
- name: Upload coverage
- uses: codecov/codecov-action@v3
+ uses: codecov/codecov-action@v5
with:
env_vars: PHP_VERSION,DB_TYPE
+ token: ${{ secrets.CODECOV_TOKEN }}
+ fail_ci_if_error: true
- name: Test app:clean-attachments
run: php bin/console partdb:attachments:clean-unused -n
@@ -130,4 +143,12 @@ jobs:
- name: Test check-requirements command
run: php bin/console partdb:check-requirements -n
-
+
+ - name: Test partdb:backup command
+ run: php bin/console partdb:backup -n --full /tmp/test_backup.zip
+
+ - name: Test legacy Part-DB import
+ run: bash .github/assets/legacy_import/test_legacy_import.sh
+ if: matrix.db-type == 'mysql' && matrix.php-versions == '8.2'
+ env:
+ DATABASE_URL: mysql://root:root@localhost:3306/legacy_db
diff --git a/.gitignore b/.gitignore
index 1d28a771..b726f64c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,3 +43,7 @@ yarn-error.log
/phpunit.xml
.phpunit.result.cache
###< phpunit/phpunit ###
+
+###> phpstan/phpstan ###
+phpstan.neon
+###< phpstan/phpstan ###
diff --git a/Dockerfile b/Dockerfile
index 720ca792..0f909f16 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,22 +1,64 @@
-FROM debian:bullseye-slim
+ARG BASE_IMAGE=debian:bookworm-slim
+ARG PHP_VERSION=8.3
+
+FROM ${BASE_IMAGE} AS base
+ARG PHP_VERSION
# Install needed dependencies for PHP build
#RUN apt-get update && apt-get install -y pkg-config curl libcurl4-openssl-dev libicu-dev \
# libpng-dev libjpeg-dev libfreetype6-dev gnupg zip libzip-dev libjpeg62-turbo-dev libonig-dev libxslt-dev libwebp-dev vim \
# && apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*
-RUN apt-get update && apt-get -y install apt-transport-https lsb-release ca-certificates curl zip \
+RUN apt-get update && apt-get -y install \
+ apt-transport-https \
+ lsb-release \
+ ca-certificates \
+ curl \
+ zip \
+ mariadb-client \
+ postgresql-client \
&& curl -sSLo /usr/share/keyrings/deb.sury.org-php.gpg https://packages.sury.org/php/apt.gpg \
&& sh -c 'echo "deb [signed-by=/usr/share/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ $(lsb_release -sc) main" > /etc/apt/sources.list.d/php.list' \
&& apt-get update && apt-get upgrade -y \
- && apt-get install -y apache2 php8.1 libapache2-mod-php8.1 php8.1-opcache php8.1-curl php8.1-gd php8.1-mbstring php8.1-xml php8.1-bcmath php8.1-intl php8.1-zip php8.1-xsl php8.1-sqlite3 php8.1-mysql gpg \
- && apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*;
-
-ENV APACHE_CONFDIR /etc/apache2
-ENV APACHE_ENVVARS $APACHE_CONFDIR/envvars
-
+ && apt-get install -y \
+ apache2 \
+ php${PHP_VERSION} \
+ php${PHP_VERSION}-fpm \
+ php${PHP_VERSION}-opcache \
+ php${PHP_VERSION}-curl \
+ php${PHP_VERSION}-gd \
+ php${PHP_VERSION}-mbstring \
+ php${PHP_VERSION}-xml \
+ php${PHP_VERSION}-bcmath \
+ php${PHP_VERSION}-intl \
+ php${PHP_VERSION}-zip \
+ php${PHP_VERSION}-xsl \
+ php${PHP_VERSION}-sqlite3 \
+ php${PHP_VERSION}-mysql \
+ php${PHP_VERSION}-pgsql \
+ gpg \
+ sudo \
+ && apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/* \
# Create workdir and set permissions if directory does not exists
-RUN mkdir -p /var/www/html && chown -R www-data:www-data /var/www/html
+ && mkdir -p /var/www/html \
+ && chown -R www-data:www-data /var/www/html \
+# delete the "index.html" that installing Apache drops in here
+ && rm -rvf /var/www/html/*
+
+# Install node and yarn
+RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
+ echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \
+ curl -sL https://deb.nodesource.com/setup_20.x | bash - && \
+ apt-get update && apt-get install -y \
+ nodejs \
+ yarn \
+ && apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*
+
+# Install composer
+COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
+
+ENV APACHE_CONFDIR=/etc/apache2
+ENV APACHE_ENVVARS=$APACHE_CONFDIR/envvars
# Configure apache 2 (taken from https://github.com/docker-library/php/blob/master/8.2/bullseye/apache/Dockerfile)
# generically convert lines like
@@ -27,8 +69,6 @@ RUN mkdir -p /var/www/html && chown -R www-data:www-data /var/www/html
# so that they can be overridden at runtime ("-e APACHE_RUN_USER=...")
RUN sed -ri 's/^export ([^=]+)=(.*)$/: ${\1:=\2}\nexport \1/' "$APACHE_ENVVARS"; \
set -eux; . "$APACHE_ENVVARS"; \
- # delete the "index.html" that installing Apache drops in here
- rm -rvf /var/www/html/*; \
\
# logs should go to stdout / stderr
ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log"; \
@@ -36,66 +76,87 @@ RUN sed -ri 's/^export ([^=]+)=(.*)$/: ${\1:=\2}\nexport \1/' "$APACHE_ENVVARS"
ln -sfT /dev/stdout "$APACHE_LOG_DIR/other_vhosts_access.log"; \
chown -R --no-dereference "$APACHE_RUN_USER:$APACHE_RUN_GROUP" "$APACHE_LOG_DIR";
-# Enable mpm_prefork
-RUN a2dismod mpm_event && a2enmod mpm_prefork
+# ---
+
+FROM scratch AS apache-config
+ARG PHP_VERSION
+# Configure php-fpm to log to stdout of the container (stdout of PID 1)
+# We have to use /proc/1/fd/1 because /dev/stdout or /proc/self/fd/1 does not point to the container stdout (because we use apache as entrypoint)
+# We also disable the clear_env option to allow the use of environment variables in php-fpm
+COPY <'; \
- echo '\tSetHandler application/x-httpd-php'; \
- echo ''; \
- echo; \
- echo 'DirectoryIndex disabled'; \
- echo 'DirectoryIndex index.php index.html'; \
- echo; \
- echo ''; \
- echo '\tOptions -Indexes'; \
- echo '\tAllowOverride All'; \
- echo ''; \
- } | tee "$APACHE_CONFDIR/conf-available/docker-php.conf" \
- && a2enconf docker-php
+COPY <
+ SetHandler application/x-httpd-php
+
+
+DirectoryIndex disabled
+DirectoryIndex index.php index.html
+
+
+ Options -Indexes
+ AllowOverride All
+
+EOF
# Enable opcache and configure it recommended for symfony (see https://symfony.com/doc/current/performance.html)
-RUN \
- { \
- echo 'opcache.memory_consumption=256'; \
- echo 'opcache.max_accelerated_files=20000'; \
- echo 'opcache.validate_timestamp=0'; \
- # Configure Realpath cache for performance
- echo 'realpath_cache_size=4096K'; \
- echo 'realpath_cache_ttl=600'; \
- } > /etc/php/8.1/apache2/conf.d/symfony-recommended.ini
+COPY < /etc/php/8.1/apache2/conf.d/partdb.ini
+# Increase upload limit and enable preloading
+COPY <
## Features
-* Inventory management of your electronic parts. Each part can be assigned to a category, footprint, manufacturer
-and multiple store locations and price information. Parts can be grouped using tags. You can associate various files like datasheets or pictures with the parts.
-* Multi-Language support (currently German, English, Russian, Japanese and French (experimental))
+
+* Inventory management of your electronic parts. Each part can be assigned to a category, footprint, manufacturer,
+ and multiple store locations and price information. Parts can be grouped using tags. You can associate various files
+ like datasheets or pictures with the parts.
+* Multi-language support (currently German, English, Russian, Japanese, French, Czech, Danish, and Chinese)
* Barcodes/Labels generator for parts and storage locations, scan barcodes via webcam using the builtin barcode scanner
-* User system with groups and detailed (fine granular) permissions.
-Two-factor authentication is supported (Google Authenticator and Webauthn/U2F keys) and can be enforced for groups. Password reset via email can be setuped.
-* Import/Export system (partial working)
-* Project management: Create projects and assign parts to the bill of material (BOM), to show how often you could build this project and directly withdraw all components needed from DB
-* Event log: Track what changes happens to your inventory, track which user does what. Revert your parts to older versions.
-* Responsive design: You can use Part-DB on your PC, your tablet and your smartphone using the same interface.
-* MySQL and SQLite supported as database backends
+* User system with groups and detailed (fine granular) permissions.
+ Two-factor authentication is supported (Google Authenticator and Webauthn/U2F keys) and can be enforced for groups.
+ Password reset via email can be set up.
+* Optional support for single sign-on (SSO) via SAML (using an intermediate service
+ like [Keycloak](https://www.keycloak.org/) you can connect Part-DB to an existing LDAP or Active Directory server)
+* Import/Export system for parts and data structure. BOM import for projects from KiCAD is supported.
+* Project management: Create projects and assign parts to the bill of material (BOM), to show how often you could build
+ this project and directly withdraw all components needed from DB
+* Event log: Track what changes happen to your inventory, track which user does what. Revert your parts to older
+ versions.
+* Responsive design: You can use Part-DB on your PC, your tablet, and your smartphone using the same interface.
+* MySQL, SQLite and PostgreSQL are supported as database backends
* Support for rich text descriptions and comments in parts
* Support for multiple currencies and automatic update of exchange rates supported
* Powerful search and filter function, including parametric search (search for parts according to some specifications)
* Automatic thumbnail generation for pictures
+* Use cloud providers (like Octopart, Digikey, Farnell, LCSC or TME) to automatically get part information, datasheets, and
+ prices for parts
+* API to access Part-DB from other applications/scripts
+* [Integration with KiCad](https://docs.part-db.de/usage/eda_integration.html): Use Part-DB as the central datasource for your
+ KiCad and see available parts from Part-DB directly inside KiCad.
-
-With these features Part-DB is useful to hobbyists, who want to keep track of their private electronic parts inventory,
-or makerspaces, where many users have should have (controlled) access to the shared inventory.
+With these features, Part-DB is useful to hobbyists, who want to keep track of their private electronic parts inventory,
+or maker spaces, where many users should have (controlled) access to the shared inventory.
Part-DB is also used by small companies and universities for managing their inventory.
## Requirements
- * A **web server** (like Apache2 or nginx) that is capable of running [Symfony 5](https://symfony.com/doc/current/reference/requirements.html),
- this includes a minimum PHP version of **PHP 7.4**
- * A **MySQL** (at least 5.7) /**MariaDB** (at least 10.2.2) database server if you do not want to use SQLite.
- * Shell access to your server is highly suggested!
- * For building the client side assets **yarn** and **nodejs** is needed.
-
+
+* A **web server** (like Apache2 or nginx) that is capable of
+ running [Symfony 6](https://symfony.com/doc/current/reference/requirements.html),
+ this includes a minimum PHP version of **PHP 8.1**
+* A **MySQL** (at least 5.7) /**MariaDB** (at least 10.4) database server, or **PostgreSQL** 10+ if you do not want to use SQLite.
+* Shell access to your server is highly recommended!
+* For building the client-side assets **yarn** and **nodejs** (>= 18.0) is needed.
+
## Installation
-If you want to upgrade your legacy (< 1.0.0) version of Part-DB to this version, please read [this](https://docs.part-db.de/upgrade_legacy.html) first.
-*Hint:* A docker image is available under [jbtronics/part-db1](https://hub.docker.com/r/jbtronics/part-db1). How to set up Part-DB via docker is described [here](https://docs.part-db.de/installation/installation_docker.html).
+If you want to upgrade your legacy (< 1.0.0) version of Part-DB to this version, please
+read [this](https://docs.part-db.de/upgrade_legacy.html) first.
-**Below you find some very rough outline of the installation process, see [here](https://docs.part-db.de/installation/) for a detailed guide how to install Part-DB.**
+*Hint:* A docker image is available under [jbtronics/part-db1](https://hub.docker.com/r/jbtronics/part-db1). How to set
+up Part-DB via docker is described [here](https://docs.part-db.de/installation/installation_docker.html).
+
+**Below you find a very rough outline of the installation process, see [here](https://docs.part-db.de/installation/)
+for a detailed guide on how to install Part-DB.**
1. Copy or clone this repository into a folder on your server.
-2. Configure your webserver to serve from the `public/` folder. See [here](https://symfony.com/doc/current/setup/web_server_configuration.html)
-for additional information.
+2. Configure your webserver to serve from the `public/` folder.
+ See [here](https://symfony.com/doc/current/setup/web_server_configuration.html)
+ for additional information.
3. Copy the global config file `cp .env .env.local` and edit `.env.local`:
* Change the line `APP_ENV=dev` to `APP_ENV=prod`
- * If you do not want to use SQLite, change the value of `DATABASE_URL=` to your needs (see [here](http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url)) for the format.
- In bigger instances with concurrent accesses, MySQL is more performant. This can not be changed easily later, so choose wisely.
+ * If you do not want to use SQLite, change the value of `DATABASE_URL=` to your needs (
+ see [here](http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url))
+ for the format.
+ In bigger instances with concurrent accesses, MySQL is more performant. This can not be changed easily later, so
+ choose wisely.
4. Install composer dependencies and generate autoload files: `composer install -o --no-dev`
-5. If you have put Part-DB into a sub-directory on your server (like `part-db/`), you have to edit the file
-`webpack.config.js` and uncomment the lines (remove the `//` before the lines) `.setPublicPath('/part-db/build')` (line 43) and
- `.setManifestKeyPrefix('build/')` (line 44). You have to replace `/part-db` with your own path on line 44.
-6. Install client side dependencies and build it: `yarn install` and `yarn build`
-7. _Optional_ (speeds up first load): Warmup cache: `php bin/console cache:warmup`
-8. Upgrade database to new scheme (or create it, when it was empty): `php bin/console doctrine:migrations:migrate` and follow the instructions given. During the process the password for the admin is user is shown. Copy it. **Caution**: This steps tamper with your database and could potentially destroy it. So make sure to make a backup of your database.
-9. You can configure Part-DB via `config/parameters.yaml`. You should check if settings match your expectations, after you installed/upgraded Part-DB. Check if `partdb.default_currency` matches your mainly used currency (this can not be changed after creating price informations).
- Run `php bin/console cache:clear` when you changed something.
-10. Access Part-DB in your browser (under the URL you put it) and login with user *admin*. Password is the one outputted during DB setup.
- If you can not remember the password, set a new one with `php bin/console app:set-password admin`. You can create new users with the admin user and start using Part-DB.
+5. Install client side dependencies and build it: `yarn install` and `yarn build`
+6. _Optional_ (speeds up first load): Warmup cache: `php bin/console cache:warmup`
+7. Upgrade database to new scheme (or create it, when it was empty): `php bin/console doctrine:migrations:migrate` and
+ follow the instructions given. During the process the password for the admin is user is shown. Copy it. **Caution**:
+ These steps tamper with your database and could potentially destroy it. So make sure to make a backup of your
+ database.
+8. You can configure Part-DB via `config/parameters.yaml`. You should check if settings match your expectations after
+ you installed/upgraded Part-DB. Check if `partdb.default_currency` matches your mainly used currency (this can not be
+ changed after creating price information).
+ Run `php bin/console cache:clear` when you change something.
+9. Access Part-DB in your browser (under the URL you put it) and log in with user *admin*. Password is the one outputted
+ during DB setup.
+ If you can not remember the password, set a new one with `php bin/console app:set-password admin`. You can create
+ new users with the admin user and start using Part-DB.
When you want to upgrade to a newer version, then just copy the new files into the folder
and repeat the steps 4. to 7.
-Normally a random password is generated when the admin user is created during inital database creation,
-however you can set the inital admin password, by setting the `INITIAL_ADMIN_PW` env var.
+Normally a random password is generated when the admin user is created during initial database creation,
+however, you can set the initial admin password, by setting the `INITIAL_ADMIN_PW` env var.
-You can configure Part-DB to your needs by changing environment variables in the `.env.local` file.
+You can configure Part-DB to your needs by changing environment variables in the `.env.local` file.
See [here](https://docs.part-db.de/configuration.html) for more information.
### Reverse proxy
-If you are using a reverse proxy, you have to ensure that the proxies sets the `X-Forwarded-*` headers correctly, or you will get HTTP/HTTPS mixup and wrong hostnames.
-If the reverse proxy is on a different server (or it cannot access Part-DB via localhost) you have to set the `TRUSTED_PROXIES` env variable to match your reverse proxies IP-address (or IP block). You can do this in your `.env.local` or (when using docker) in your `docker-compose.yml` file.
+
+If you are using a reverse proxy, you have to ensure that the proxies set the `X-Forwarded-*` headers correctly, or you
+will get HTTP/HTTPS mixup and wrong hostnames.
+If the reverse proxy is on a different server (or it cannot access Part-DB via localhost) you have to set
+the `TRUSTED_PROXIES` env variable to match your reverse proxy's IP address (or IP block). You can do this in
+your `.env.local` or (when using docker) in your `docker-compose.yml` file.
## Donate for development
+
If you want to donate to the Part-DB developer, see the sponsor button in the top bar (next to the repo name).
-There you will find various methods to support development on a monthly or a one time base.
+There you will find various methods to support development on a monthly or a one-time base.
## Built with
+
* [Symfony 5](https://symfony.com/): The main framework used for the serverside PHP
* [Bootstrap 5](https://getbootstrap.com/) and [Bootswatch](https://bootswatch.com/): Used as website theme
* [Fontawesome](https://fontawesome.com/): Used as icon set
-* [Hotwire Stimulus](https://stimulus.hotwired.dev/) and [Hotwire Turbo](https://turbo.hotwired.dev/): Frontend Javascript
+* [Hotwire Stimulus](https://stimulus.hotwired.dev/) and [Hotwire Turbo](https://turbo.hotwired.dev/): Frontend
+ Javascript
## Authors
-* **Jan Böhmer** - *Inital work* - [Github](https://github.com/jbtronics/)
-See also the list of [contributors](https://github.com/Part-DB/Part-DB-server/graphs/contributors) who participated in this project.
+* **Jan Böhmer** - *Initial work* - [GitHub](https://github.com/jbtronics/)
+
+See also the list of [contributors](https://github.com/Part-DB/Part-DB-server/graphs/contributors) who participated in
+this project.
Based on the original Part-DB by Christoph Lechner and K. Jacobs
## License
+
Part-DB is licensed under the GNU Affero General Public License v3.0 (or at your opinion any later).
This mostly means that you can use Part-DB for whatever you want (even use it commercially)
as long as you publish the source code for every change you make under the AGPL, too.
diff --git a/SECURITY.md b/SECURITY.md
index 02775f95..a9234a01 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -9,4 +9,4 @@ fixed before the next release. However, if you find a security vulnerability in
## Reporting a Vulnerability
-If you find a security vulnerability, contact the maintainer directly (Email: security@part-db.de).
+If you find a security vulnerability, report a vulnerability in the [security section of GitHub](https://github.com/Part-DB/Part-DB-server/security/advisories) or contact the maintainer directly (Email: security@part-db.de)
diff --git a/VERSION b/VERSION
index afaf360d..511a76e6 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.0.0
\ No newline at end of file
+1.17.1
diff --git a/assets/bootstrap.js b/assets/bootstrap.js
index 58308a6b..c26293e2 100644
--- a/assets/bootstrap.js
+++ b/assets/bootstrap.js
@@ -4,8 +4,7 @@ import { startStimulusApp } from '@symfony/stimulus-bridge';
export const app = startStimulusApp(require.context(
'@symfony/stimulus-bridge/lazy-controller-loader!./controllers',
true,
- /\.(j|t)sx?$/
+ /\.[jt]sx?$/
));
-
// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);
diff --git a/assets/ckeditor/html_label.js b/assets/ckeditor/html_label.js
index b5ca5c3e..9040f3c7 100644
--- a/assets/ckeditor/html_label.js
+++ b/assets/ckeditor/html_label.js
@@ -181,7 +181,8 @@ Editor.defaultConfig = {
'DejaVu Serif, serif',
'Helvetica, Arial, sans-serif',
'Times New Roman, Times, serif',
- 'Courier New, Courier, monospace'
+ 'Courier New, Courier, monospace',
+ 'Unifont, monospace',
],
supportAllValues: true
},
diff --git a/assets/ckeditor/plugins/PartDBLabel/PartDBLabelUI.js b/assets/ckeditor/plugins/PartDBLabel/PartDBLabelUI.js
index 5dddbb01..37e1dcbe 100644
--- a/assets/ckeditor/plugins/PartDBLabel/PartDBLabelUI.js
+++ b/assets/ckeditor/plugins/PartDBLabel/PartDBLabelUI.js
@@ -76,6 +76,7 @@ const PLACEHOLDERS = [
['[[FOOTPRINT_FULL]]', 'Footprint (Full path)'],
['[[MASS]]', 'Mass'],
['[[MPN]]', 'Manufacturer Product Number (MPN)'],
+ ['[[IPN]]', 'Internal Part Number (IPN)'],
['[[TAGS]]', 'Tags'],
['[[M_STATUS]]', 'Manufacturing status'],
['[[DESCRIPTION]]', 'Description'],
@@ -84,6 +85,9 @@ const PLACEHOLDERS = [
['[[COMMENT_T]]', 'Comment (plain text)'],
['[[LAST_MODIFIED]]', 'Last modified datetime'],
['[[CREATION_DATE]]', 'Creation datetime'],
+ ['[[IPN_BARCODE_QR]]', 'IPN as QR code'],
+ ['[[IPN_BARCODE_C128]]', 'IPN as Code 128 barcode'],
+ ['[[IPN_BARCODE_C39]]', 'IPN as Code 39 barcode'],
]
},
{
@@ -96,6 +100,8 @@ const PLACEHOLDERS = [
['[[AMOUNT]]', 'Lot amount'],
['[[LOCATION]]', 'Storage location'],
['[[LOCATION_FULL]]', 'Storage location (Full path)'],
+ ['[[OWNER]]', 'Full name of the lot owner'],
+ ['[[OWNER_USERNAME]]', 'Username of the lot owner'],
]
},
{
@@ -110,6 +116,8 @@ const PLACEHOLDERS = [
['[[COMMENT_T]]', 'Comment (plain text)'],
['[[LAST_MODIFIED]]', 'Last modified datetime'],
['[[CREATION_DATE]]', 'Creation datetime'],
+ ['[[OWNER]]', 'Full name of the location owner'],
+ ['[[OWNER_USERNAME]]', 'Username of the location owner'],
]
},
{
@@ -120,6 +128,8 @@ const PLACEHOLDERS = [
['[[BARCODE_QR]]', 'QR code linking to this element'],
['[[BARCODE_C128]]', 'Code 128 barcode linking to this element'],
['[[BARCODE_C39]]', 'Code 39 barcode linking to this element'],
+ ['[[BARCODE_C93]]', 'Code 93 barcode linking to this element'],
+ ['[[BARCODE_DATAMATRIX]]', 'Datamatrix code linking to this element'],
]
},
{
diff --git a/assets/ckeditor/plugins/PartDBLabel/lang/de.js b/assets/ckeditor/plugins/PartDBLabel/lang/de.js
index d49a26ed..748b1607 100644
--- a/assets/ckeditor/plugins/PartDBLabel/lang/de.js
+++ b/assets/ckeditor/plugins/PartDBLabel/lang/de.js
@@ -39,6 +39,7 @@ Object.assign( window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary, {
'Footprint (Full path)': 'Footprint (Vollständiger Pfad)',
'Mass': 'Gewicht',
'Manufacturer Product Number (MPN)': 'Hersteller Produktnummer (MPN)',
+ 'Internal Part Number (IPN)': 'Internal Part Number (IPN)',
'Tags': 'Tags',
'Manufacturing status': 'Herstellungsstatus',
'Description': 'Beschreibung',
@@ -47,6 +48,9 @@ Object.assign( window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary, {
'Comment (plain text)': 'Kommentar (Nur-Text)',
'Last modified datetime': 'Zuletzt geändert',
'Creation datetime': 'Erstellt',
+ 'IPN as QR code': 'IPN als QR Code',
+ 'IPN as Code 128 barcode': 'IPN als Code 128 Barcode',
+ 'IPN as Code 39 barcode': 'IPN als Code 39 Barcode',
'Lot ID': 'Lot ID',
'Lot name': 'Lot Name',
@@ -55,6 +59,8 @@ Object.assign( window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary, {
'Lot amount': 'Lot Menge',
'Storage location': 'Lagerort',
'Storage location (Full path)': 'Lagerort (Vollständiger Pfad)',
+ 'Full name of the lot owner': 'Name des Besitzers des Lots',
+ 'Username of the lot owner': 'Benutzername des Besitzers des Lots',
'Barcodes': 'Barcodes',
@@ -63,12 +69,16 @@ Object.assign( window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary, {
'QR code linking to this element': 'QR Code verknüpft mit diesem Element',
'Code 128 barcode linking to this element': 'Code 128 Barcode verknüpft mit diesem Element',
'Code 39 barcode linking to this element': 'Code 39 Barcode verknüpft mit diesem Element',
+ 'Code 93 barcode linking to this element': 'Code 93 Barcode verknüpft mit diesem Element',
+ 'Datamatrix code linking to this element': 'Datamatrix Code verknüpft mit diesem Element',
'Location ID': 'Lagerort ID',
'Name': 'Name',
'Full path': 'Vollständiger Pfad',
'Parent name': 'Name des Übergeordneten Elements',
'Parent full path': 'Ganzer Pfad des Übergeordneten Elements',
+ 'Full name of the location owner': 'Name des Besitzers des Lagerorts',
+ 'Username of the location owner': 'Benutzername des Besitzers des Lagerorts',
'Username': 'Benutzername',
'Username (including name)': 'Benutzername (inklusive Name)',
diff --git a/assets/ckeditor/plugins/special_characters_emoji.js b/assets/ckeditor/plugins/special_characters_emoji.js
index 9877e6f6..1d4ec000 100644
--- a/assets/ckeditor/plugins/special_characters_emoji.js
+++ b/assets/ckeditor/plugins/special_characters_emoji.js
@@ -30,9 +30,73 @@ export default class SpecialCharactersEmoji extends Plugin {
const editor = this.editor;
const specialCharsPlugin = editor.plugins.get('SpecialCharacters');
+ //Add greek characters to special characters
+ specialCharsPlugin.addItems('Greek', this.getGreek());
+
+ //Add Emojis to special characters
specialCharsPlugin.addItems('Emoji', this.getEmojis());
}
+ getGreek() {
+ return [
+ { title: 'Alpha', character: 'Α' },
+ { title: 'Beta', character: 'Β' },
+ { title: 'Gamma', character: 'Γ' },
+ { title: 'Delta', character: 'Δ' },
+ { title: 'Epsilon', character: 'Ε' },
+ { title: 'Zeta', character: 'Ζ' },
+ { title: 'Eta', character: 'Η' },
+ { title: 'Theta', character: 'Θ' },
+ { title: 'Iota', character: 'Ι' },
+ { title: 'Kappa', character: 'Κ' },
+ { title: 'Lambda', character: 'Λ' },
+ { title: 'Mu', character: 'Μ' },
+ { title: 'Nu', character: 'Ν' },
+ { title: 'Xi', character: 'Ξ' },
+ { title: 'Omicron', character: 'Ο' },
+ { title: 'Pi', character: 'Π' },
+ { title: 'Rho', character: 'Ρ' },
+ { title: 'Sigma', character: 'Σ' },
+ { title: 'Tau', character: 'Τ' },
+ { title: 'Upsilon', character: 'Υ' },
+ { title: 'Phi', character: 'Φ' },
+ { title: 'Chi', character: 'Χ' },
+ { title: 'Psi', character: 'Ψ' },
+ { title: 'Omega', character: 'Ω' },
+ { title: 'alpha', character: 'α' },
+ { title: 'beta', character: 'β' },
+ { title: 'gamma', character: 'γ' },
+ { title: 'delta', character: 'δ' },
+ { title: 'epsilon', character: 'ε' },
+ { title: 'zeta', character: 'ζ' },
+ { title: 'eta', character: 'η' },
+ { title: 'theta', character: 'θ' },
+ { title: 'alternate theta', character: 'ϑ' },
+ { title: 'iota', character: 'ι' },
+ { title: 'kappa', character: 'κ' },
+ { title: 'lambda', character: 'λ' },
+ { title: 'mu', character: 'μ' },
+ { title: 'nu', character: 'ν' },
+ { title: 'xi', character: 'ξ' },
+ { title: 'omicron', character: 'ο' },
+ { title: 'pi', character: 'π' },
+ { title: 'rho', character: 'ρ' },
+ { title: 'sigma', character: 'σ' },
+ { title: 'tau', character: 'τ' },
+ { title: 'upsilon', character: 'υ' },
+ { title: 'phi', character: 'φ' },
+ { title: 'chi', character: 'χ' },
+ { title: 'psi', character: 'ψ' },
+ { title: 'omega', character: 'ω' },
+ { title: 'digamma', character: 'Ϝ' },
+ { title: 'stigma', character: 'Ϛ' },
+ { title: 'heta', character: 'Ͱ' },
+ { title: 'sampi', character: 'Ϡ' },
+ { title: 'koppa', character: 'Ϟ' },
+ { title: 'san', character: 'Ϻ' },
+ ];
+ }
+
getEmojis() {
//Map our emoji data to the format the plugin expects
return emoji.map(emoji => {
diff --git a/assets/controllers/common/darkmode_controller.js b/assets/controllers/common/darkmode_controller.js
index e7c18e67..71111166 100644
--- a/assets/controllers/common/darkmode_controller.js
+++ b/assets/controllers/common/darkmode_controller.js
@@ -18,43 +18,118 @@
*/
import {Controller} from "@hotwired/stimulus";
-import Darkmode from "darkmode-js/src";
-import "darkmode-js"
export default class extends Controller {
- _darkmode;
-
connect() {
- if (typeof window.getComputedStyle(document.body).mixBlendMode == 'undefined') {
- console.warn("The browser does not support mix blend mode. Darkmode will not work.");
+ this.setMode(this.getMode());
+ document.querySelectorAll('input[name="darkmode"]').forEach((radio) => {
+ radio.addEventListener('change', this._radioChanged.bind(this));
+ });
+ }
+
+ /**
+ * Event listener for the change of radio buttons
+ * @private
+ */
+ _radioChanged(event) {
+ const new_mode = this.getSelectedMode();
+ this.setMode(new_mode);
+ }
+
+ /**
+ * Get the current mode from the local storage
+ * @return {'dark', 'light', 'auto'}
+ */
+ getMode() {
+ return localStorage.getItem('darkmode') ?? 'auto';
+ }
+
+ /**
+ * Set the mode in the local storage and apply it and change the state of the radio buttons
+ * @param mode
+ */
+ setMode(mode) {
+ if (mode !== 'dark' && mode !== 'light' && mode !== 'auto') {
+ console.warn('Invalid darkmode mode: ' + mode);
+ mode = 'auto';
+ }
+
+ localStorage.setItem('darkmode', mode);
+
+ this.setButtonMode(mode);
+
+ if (mode === 'auto') {
+ this._setDarkmodeAuto();
+ } else if (mode === 'dark') {
+ this._enableDarkmode();
+ } else if (mode === 'light') {
+ this._disableDarkmode();
+ }
+ }
+
+ /**
+ * Get the selected mode via the radio buttons
+ * @return {'dark', 'light', 'auto'}
+ */
+ getSelectedMode() {
+ return document.querySelector('input[name="darkmode"]:checked').value;
+ }
+
+ /**
+ * Set the state of the radio buttons
+ * @param mode
+ */
+ setButtonMode(mode) {
+ document.querySelector('input[name="darkmode"][value="' + mode + '"]').checked = true;
+ }
+
+ /**
+ * Enable darkmode by adding the data-bs-theme="dark" to the html tag
+ * @private
+ */
+ _enableDarkmode() {
+ //Add data-bs-theme="dark" to the html tag
+ document.documentElement.setAttribute('data-bs-theme', 'dark');
+ }
+
+ /**
+ * Disable darkmode by adding the data-bs-theme="light" to the html tag
+ * @private
+ */
+ _disableDarkmode() {
+ //Set data-bs-theme to light
+ document.documentElement.setAttribute('data-bs-theme', 'light');
+ }
+
+
+ /**
+ * Set the darkmode to auto and enable/disable it depending on the system settings, also add
+ * an event listener to change the darkmode if the system settings change
+ * @private
+ */
+ _setDarkmodeAuto() {
+ if (this.getMode() !== 'auto') {
return;
}
- try {
- const darkmode = new Darkmode();
- this._darkmode = darkmode;
-
- //Unhide darkmode button
- this._showWidget();
-
- //Set the switch according to our current darkmode state
- const toggler = document.getElementById("toggleDarkmode");
- toggler.checked = darkmode.isActivated();
- }
- catch (e)
- {
- console.error(e);
+ if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
+ this._enableDarkmode();
+ } else {
+ this._disableDarkmode();
}
-
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
+ console.log('Prefered color scheme changed to ' + event.matches ? 'dark' : 'light');
+ this._setDarkmodeAuto();
+ });
}
- _showWidget() {
- this.element.classList.remove('hidden');
- }
-
- toggleDarkmode() {
- this._darkmode.toggle();
+ /**
+ * Check if darkmode is activated
+ * @return {boolean}
+ */
+ isDarkmodeActivated() {
+ return document.documentElement.getAttribute('data-bs-theme') === 'dark';
}
}
\ No newline at end of file
diff --git a/assets/controllers/common/hide_sidebar_controller.js b/assets/controllers/common/hide_sidebar_controller.js
index d4c1b5e2..4be304ff 100644
--- a/assets/controllers/common/hide_sidebar_controller.js
+++ b/assets/controllers/common/hide_sidebar_controller.js
@@ -88,5 +88,8 @@ export default class extends Controller {
} else {
this.hideSidebar();
}
+
+ //Hide the tootip on the button
+ this._toggle_button.blur();
}
}
\ No newline at end of file
diff --git a/assets/controllers/common/markdown_controller.js b/assets/controllers/common/markdown_controller.js
index 08c013ca..b6ef0034 100644
--- a/assets/controllers/common/markdown_controller.js
+++ b/assets/controllers/common/markdown_controller.js
@@ -20,16 +20,26 @@
'use strict';
import { Controller } from '@hotwired/stimulus';
-import { marked } from "marked";
+import { Marked } from "marked";
+import { mangle } from "marked-mangle";
+import { gfmHeadingId } from "marked-gfm-heading-id";
import DOMPurify from 'dompurify';
import "../../css/app/markdown.css";
-export default class extends Controller {
+export default class MarkdownController extends Controller {
+
+ static _marked = new Marked([
+ {
+ gfm: true,
+ },
+ gfmHeadingId(),
+ mangle(),
+ ])
+ ;
connect()
{
- this.configureMarked();
this.render();
//Dispatch an event that we are now finished
@@ -43,7 +53,7 @@ export default class extends Controller {
let raw = this.element.dataset['markdown'];
//Apply purified parsed markdown
- this.element.innerHTML = DOMPurify.sanitize(marked(this.unescapeHTML(raw)));
+ this.element.innerHTML = DOMPurify.sanitize(MarkdownController._marked.parse(this.unescapeHTML(raw)));
for(let a of this.element.querySelectorAll('a')) {
//Mark all links as external
@@ -79,10 +89,23 @@ export default class extends Controller {
/**
* Configure the marked parser
*/
- configureMarked()
+ /*static newMarked()
{
+ const marked = new Marked([
+ {
+ gfm: true,
+ },
+ gfmHeadingId(),
+ mangle(),
+ ])
+ ;
+
+ marked.use(mangle());
+ marked.use(gfmHeadingId({
+ }));
+
marked.setOptions({
gfm: true,
});
- }
+ }*/
}
\ No newline at end of file
diff --git a/assets/controllers/elements/attachment_autocomplete_controller.js b/assets/controllers/elements/attachment_autocomplete_controller.js
index fe44baee..f8bc301e 100644
--- a/assets/controllers/elements/attachment_autocomplete_controller.js
+++ b/assets/controllers/elements/attachment_autocomplete_controller.js
@@ -23,6 +23,12 @@ import "tom-select/dist/css/tom-select.bootstrap5.css";
import '../../css/components/tom-select_extensions.css';
import TomSelect from "tom-select";
+import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
+import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
+
+TomSelect.define('click_to_edit', TomSelect_click_to_edit)
+TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
+
export default class extends Controller {
_tomSelect;
@@ -46,6 +52,12 @@ export default class extends Controller {
}
return '
' + escape(data.label) + '
';
}
+ },
+ plugins: {
+ 'autoselect_typed': {},
+ 'click_to_edit': {},
+ 'clear_button': {},
+ "restore_on_backspace": {}
}
};
diff --git a/assets/controllers/elements/ckeditor_controller.js b/assets/controllers/elements/ckeditor_controller.js
index 4de536fe..079ee2ad 100644
--- a/assets/controllers/elements/ckeditor_controller.js
+++ b/assets/controllers/elements/ckeditor_controller.js
@@ -53,6 +53,7 @@ export default class extends Controller {
const config = {
language: language,
+ licenseKey: "GPL",
}
const watchdog = new EditorWatchdog();
@@ -70,7 +71,9 @@ export default class extends Controller {
editor_div.classList.add(...new_classes.split(","));
}
- console.log(editor);
+ //This return is important! Otherwise we get mysterious errors in the console
+ //See: https://github.com/ckeditor/ckeditor5/issues/5897#issuecomment-628471302
+ return editor;
})
.catch(error => {
console.error(error);
diff --git a/assets/controllers/elements/collection_type_controller.js b/assets/controllers/elements/collection_type_controller.js
index 488c6421..8b816f30 100644
--- a/assets/controllers/elements/collection_type_controller.js
+++ b/assets/controllers/elements/collection_type_controller.js
@@ -27,6 +27,7 @@ export default class extends Controller {
deleteMessage: String,
prototype: String,
rowsToDelete: Number, //How many rows (including the current one) shall be deleted after the current row
+ fieldPlaceholder: String
}
static targets = ["target"];
@@ -60,24 +61,63 @@ export default class extends Controller {
if(!prototype) {
console.warn("Prototype is not set, we cannot create a new element. This is most likely due to missing permissions.");
- bootbox.alert("You do not have the permsissions to create a new element. (No protoype element is set)");
+ bootbox.alert("You do not have the permissions to create a new element. (No protoype element is set)");
return;
}
+ const regexString = this.fieldPlaceholderValue || "__name__";
+ const regex = new RegExp(regexString, "g");
+
//Apply the index to prototype to create our element to insert
- const newElementStr = this.htmlDecode(prototype.replace(/__name__/g, this.generateUID()));
+ const newElementStr = this.htmlDecode(prototype.replace(regex, this.generateUID()));
//Insert new html after the last child element
//If the table has a tbody, insert it there
+ //Afterwards return the newly created row
if(targetTable.tBodies[0]) {
targetTable.tBodies[0].insertAdjacentHTML('beforeend', newElementStr);
+ return targetTable.tBodies[0].lastElementChild;
} else { //Otherwise just insert it
targetTable.insertAdjacentHTML('beforeend', newElementStr);
+ return targetTable.lastElementChild;
}
}
+ /**
+ * This action opens a file dialog to select multiple files and then creates a new element for each file, where
+ * the file is assigned to the input field.
+ * This should only be used for attachments collection types
+ * @param event
+ */
+ uploadMultipleFiles(event) {
+ //Open a file dialog to select multiple files
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.multiple = true;
+ input.click();
+
+ input.addEventListener('change', (event) => {
+ //Create a element for each file
+
+ for (let i = 0; i < input.files.length; i++) {
+ const file = input.files[i];
+
+ const newElement = this.createElement(event);
+ const rowInput = newElement.querySelector("input[type='file']");
+
+ //We can not directly assign the file to the input, so we have to create a new DataTransfer object
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(file);
+
+ rowInput.files = dataTransfer.files;
+ }
+
+ });
+
+ }
+
/**
* Similar to createEvent Pricedetails need some special handling to fill min amount
* @param event
diff --git a/assets/controllers/elements/datatables/datatables_controller.js b/assets/controllers/elements/datatables/datatables_controller.js
index 2c23d855..5a50623d 100644
--- a/assets/controllers/elements/datatables/datatables_controller.js
+++ b/assets/controllers/elements/datatables/datatables_controller.js
@@ -24,18 +24,25 @@ import 'datatables.net-bs5/css/dataTables.bootstrap5.css'
import 'datatables.net-buttons-bs5/css/buttons.bootstrap5.css'
import 'datatables.net-fixedheader-bs5/css/fixedHeader.bootstrap5.css'
import 'datatables.net-responsive-bs5/css/responsive.bootstrap5.css';
-import 'datatables.net-select-bs5/css/select.bootstrap5.css';
+
+//Use our own styles for the select extension which fit the bootstrap theme better
+//import 'datatables.net-select-bs5/css/select.bootstrap5.css';
+import '../../../css/components/datatables_select_bs5.css';
//JS
import 'datatables.net-bs5';
import 'datatables.net-buttons-bs5';
import 'datatables.net-buttons/js/buttons.colVis.js';
import 'datatables.net-fixedheader-bs5';
-import 'datatables.net-select-bs5';
import 'datatables.net-colreorder-bs5';
import 'datatables.net-responsive-bs5';
import '../../../js/lib/datatables';
+//import 'datatables.net-select-bs5';
+//Use the local version containing the fix for the select extension
+import '../../../js/lib/dataTables.select.mjs';
+
+
const EVENT_DT_LOADED = 'dt:loaded';
export default class extends Controller {
@@ -65,8 +72,22 @@ export default class extends Controller {
localStorage.setItem( this.getStateSaveKey(), JSON.stringify(data) );
}
- stateLoadCallback(settings) {
- return JSON.parse( localStorage.getItem(this.getStateSaveKey()) );
+ stateLoadCallback() {
+ const json = localStorage.getItem(this.getStateSaveKey());
+ if(json === null || json === undefined) {
+ return null;
+ }
+
+ const data = JSON.parse(json);
+
+ if (data) {
+ //Do not save the start value (current page), as we want to always start at the first page on a page reload
+ delete data.start;
+ //Reset the data length to the default value by deleting the length property
+ delete data.length;
+ }
+
+ return data;
}
connect() {
@@ -81,6 +102,19 @@ export default class extends Controller {
//Add url info, as the one available in the history is not enough, as Turbo may have not changed it yet
settings.url = this.element.dataset.dtUrl;
+ //Add initial_order info to the settings, so that the order on the initial page load is the one saved in the state
+ const saved_state = this.stateLoadCallback();
+ if (saved_state !== null) {
+ const raw_order = saved_state.order;
+
+ settings.initial_order = raw_order.map((order) => {
+ return {
+ column: order[0],
+ dir: order[1]
+ }
+ });
+ }
+
let options = {
colReorder: true,
responsive: true,
@@ -90,7 +124,7 @@ export default class extends Controller {
},
buttons: [{
"extend": 'colvis',
- 'className': 'mr-2 btn-light',
+ 'className': 'mr-2 btn-outline-secondary',
'columns': ':not(.no-colvis)',
"text": ""
}],
@@ -105,7 +139,7 @@ export default class extends Controller {
if(this.isSelectable()) {
options.select = {
style: 'multi+shift',
- selector: 'td.select-checkbox'
+ selector: 'td.dt-select',
};
}
@@ -116,6 +150,28 @@ export default class extends Controller {
console.error("Error initializing datatables: " + err);
});
+ //Fix height of the length selector
+ promise.then((dt) => {
+
+ //Draw the rows to make sure the correct status text is displayed ("No matching records found" instead of "Loading...")
+ if (dt.data().length === 0) {
+ dt.rows().draw()
+ }
+
+ //Find all length selectors (select with name dt_length), which are inside a label
+ const lengthSelectors = document.querySelectorAll('label select[name="dt_length"]');
+ //And remove the surrounding label, while keeping the select with all event handlers
+ lengthSelectors.forEach((selector) => {
+ selector.parentElement.replaceWith(selector);
+ });
+
+ //Find all column visibility buttons (button with buttons-colvis class) and remove the btn-secondary class
+ const colVisButtons = document.querySelectorAll('button.buttons-colvis');
+ colVisButtons.forEach((button) => {
+ button.classList.remove('btn-secondary');
+ });
+ });
+
//Dispatch an event to let others know that the datatables has been loaded
promise.then((dt) => {
const event = new CustomEvent(EVENT_DT_LOADED, {bubbles: true});
@@ -175,4 +231,16 @@ export default class extends Controller {
return this.element.dataset.select ?? false;
}
-}
\ No newline at end of file
+ invertSelection() {
+ //Do nothing if the datatable is not selectable
+ if(!this.isSelectable()) {
+ return;
+ }
+
+ //Invert the selected rows on the datatable
+ const selected_rows = this._dt.rows({selected: true});
+ this._dt.rows().select();
+ selected_rows.deselect();
+ }
+
+}
diff --git a/assets/controllers/elements/datatables/parts_controller.js b/assets/controllers/elements/datatables/parts_controller.js
index 33362648..1fe11a20 100644
--- a/assets/controllers/elements/datatables/parts_controller.js
+++ b/assets/controllers/elements/datatables/parts_controller.js
@@ -107,6 +107,13 @@ export default class extends DatatablesController {
//Hide the select element (the tomselect button is the sibling of the select element)
select_target.nextElementSibling.classList.add('d-none');
}
+
+ //If the selected option has a data-turbo attribute, set it to the form
+ if (selected_option.dataset.turbo) {
+ this.element.dataset.turbo = selected_option.dataset.turbo;
+ } else {
+ this.element.dataset.turbo = true;
+ }
}
confirmDeletionAtSubmit(event) {
diff --git a/assets/controllers/elements/delete_btn_controller.js b/assets/controllers/elements/delete_btn_controller.js
index 21a25328..9ab15f7d 100644
--- a/assets/controllers/elements/delete_btn_controller.js
+++ b/assets/controllers/elements/delete_btn_controller.js
@@ -29,62 +29,47 @@ export default class extends Controller
this._confirmed = false;
}
- click(event) {
- //If a user has not already confirmed the deletion, just let turbo do its work
- if(this._confirmed) {
- this._confirmed = false;
- return;
- }
-
- event.preventDefault();
-
- const message = this.element.dataset.deleteMessage;
- const title = this.element.dataset.deleteTitle;
-
- const that = this;
-
- const confirm = bootbox.confirm({
- message: message, title: title, callback: function (result) {
- //If the dialog was confirmed, then submit the form.
- if (result) {
- that._confirmed = true;
- event.target.click();
- } else {
- that._confirmed = false;
- }
- }
- });
- }
-
submit(event) {
//If a user has not already confirmed the deletion, just let turbo do its work
- if(this._confirmed) {
+ if (this._confirmed) {
this._confirmed = false;
return;
}
//Prevent turbo from doing its work
event.preventDefault();
+ event.stopPropagation();
const message = this.element.dataset.deleteMessage;
const title = this.element.dataset.deleteTitle;
- const form = this.element;
+ //Use event target, to find the form, where the submit button was clicked
+ const form = event.target;
+ const submitter = event.submitter;
const that = this;
- //Create a clone of the event with the same submitter, so we can redispatch it if needed
- //We need to do this that way, as we need the submitter info, just calling form.submit() would not work
- this._our_event = new SubmitEvent('submit', {
- submitter: event.submitter,
- bubbles: true, //This line is important, otherwise Turbo will not receive the event
- });
-
const confirm = bootbox.confirm({
message: message, title: title, callback: function (result) {
//If the dialog was confirmed, then submit the form.
if (result) {
+ //Set a flag to prevent the dialog from popping up again and allowing turbo to submit the form
that._confirmed = true;
- form.dispatchEvent(that._our_event);
+
+ //Create a submit button in the form and click it to submit the form
+ //Before a submit event was dispatched, but this caused weird issues on Firefox causing the delete request being posted twice (and the second time was returning 404). See https://github.com/Part-DB/Part-DB-server/issues/273
+ const submit_btn = document.createElement('button');
+ submit_btn.type = 'submit';
+ submit_btn.style.display = 'none';
+
+ //If the clicked button has a value, set it on the submit button
+ if (submitter.value) {
+ submit_btn.value = submitter.value;
+ }
+ if (submitter.name) {
+ submit_btn.name = submitter.name;
+ }
+ form.appendChild(submit_btn);
+ submit_btn.click();
} else {
that._confirmed = false;
}
diff --git a/assets/controllers/elements/json_formatter_controller.js b/assets/controllers/elements/json_formatter_controller.js
new file mode 100644
index 00000000..c72814e3
--- /dev/null
+++ b/assets/controllers/elements/json_formatter_controller.js
@@ -0,0 +1,40 @@
+/*
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+
+import {Controller} from "@hotwired/stimulus";
+
+import JSONFormatter from 'json-formatter-js';
+
+/**
+ * This controller implements an element that renders a JSON object as a collapsible tree.
+ * The JSON object is passed as a data attribute.
+ * You have to apply the controller to a div element or similar block element which can contain other elements.
+ */
+export default class extends Controller {
+ connect() {
+ const depth_to_open = this.element.dataset.depthToOpen ?? 0;
+ const json_string = this.element.dataset.json;
+ const json_object = JSON.parse(json_string);
+
+ const formatter = new JSONFormatter(json_object, depth_to_open);
+
+ this.element.appendChild(formatter.render());
+ }
+}
\ No newline at end of file
diff --git a/assets/controllers/elements/link_confirm_controller.js b/assets/controllers/elements/link_confirm_controller.js
new file mode 100644
index 00000000..3d59b492
--- /dev/null
+++ b/assets/controllers/elements/link_confirm_controller.js
@@ -0,0 +1,72 @@
+/*
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import {Controller} from "@hotwired/stimulus";
+
+import * as bootbox from "bootbox";
+import "../../css/components/bootbox_extensions.css";
+
+export default class extends Controller
+{
+
+ static values = {
+ message: String,
+ title: String
+ }
+
+
+
+ connect()
+ {
+ this._confirmed = false;
+
+ this.element.addEventListener('click', this._onClick.bind(this));
+ }
+
+ _onClick(event)
+ {
+
+ //If a user has not already confirmed the deletion, just let turbo do its work
+ if (this._confirmed) {
+ this._confirmed = false;
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ const that = this;
+
+ bootbox.confirm({
+ title: this.titleValue,
+ message: this.messageValue,
+ callback: (result) => {
+ if (result) {
+ //Set a flag to prevent the dialog from popping up again and allowing turbo to submit the form
+ that._confirmed = true;
+
+ //Click the link
+ that.element.click();
+ } else {
+ that._confirmed = false;
+ }
+ }
+ });
+ }
+}
\ No newline at end of file
diff --git a/assets/controllers/elements/localStorage_checkbox_controller.js b/assets/controllers/elements/localStorage_checkbox_controller.js
new file mode 100644
index 00000000..70ef877d
--- /dev/null
+++ b/assets/controllers/elements/localStorage_checkbox_controller.js
@@ -0,0 +1,67 @@
+/*
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import {Controller} from "@hotwired/stimulus";
+
+export default class extends Controller
+{
+ static values = {
+ id: String
+ }
+
+ connect() {
+ this.loadState()
+ this.element.addEventListener('change', () => {
+ this.saveState()
+ });
+ }
+
+ loadState() {
+ let storageKey = this.getStorageKey();
+ let value = localStorage.getItem(storageKey);
+ if (value === null) {
+ return;
+ }
+
+ if (value === 'true') {
+ this.element.checked = true
+ }
+ if (value === 'false') {
+ this.element.checked = false
+ }
+ }
+
+ saveState() {
+ let storageKey = this.getStorageKey();
+
+ if (this.element.checked) {
+ localStorage.setItem(storageKey, 'true');
+ } else {
+ localStorage.setItem(storageKey, 'false');
+ }
+ }
+
+ getStorageKey() {
+ if (this.hasIdValue) {
+ return 'persistent_checkbox_' + this.idValue
+ }
+
+ return 'persistent_checkbox_' + this.element.id;
+ }
+}
diff --git a/assets/controllers/elements/part_search_controller.js b/assets/controllers/elements/part_search_controller.js
new file mode 100644
index 00000000..c33cece0
--- /dev/null
+++ b/assets/controllers/elements/part_search_controller.js
@@ -0,0 +1,200 @@
+/*
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { Controller } from "@hotwired/stimulus";
+import { autocomplete } from '@algolia/autocomplete-js';
+//import "@algolia/autocomplete-theme-classic/dist/theme.css";
+import "../../css/components/autocomplete_bootstrap_theme.css";
+import { createLocalStorageRecentSearchesPlugin } from '@algolia/autocomplete-plugin-recent-searches';
+import {marked} from "marked";
+
+import {
+ trans,
+ SEARCH_PLACEHOLDER,
+ SEARCH_SUBMIT,
+ STATISTICS_PARTS
+} from '../../translator';
+
+
+/**
+ * This controller is responsible for the search fields in the navbar and the homepage.
+ * It uses the Algolia Autocomplete library to provide a fast and responsive search.
+ */
+export default class extends Controller {
+
+ static targets = ["input"];
+
+ _autocomplete;
+
+ // Highlight the search query in the results
+ _highlight = (text, query) => {
+ if (!text) return text;
+ if (!query) return text;
+
+ const HIGHLIGHT_PRE_TAG = '__aa-highlight__'
+ const HIGHLIGHT_POST_TAG = '__/aa-highlight__'
+
+ const escaped = query.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
+ const regex = new RegExp(escaped, 'gi');
+
+ return text.replace(regex, (match) => `${HIGHLIGHT_PRE_TAG}${match}${HIGHLIGHT_POST_TAG}`);
+ }
+
+ initialize() {
+ // The endpoint for searching parts
+ const base_url = this.element.dataset.autocomplete;
+ // The URL template for the part detail pages
+ const part_detail_uri_template = this.element.dataset.detailUrl;
+
+ //The URL of the placeholder picture
+ const placeholder_image = this.element.dataset.placeholderImage;
+
+ //If the element is in navbar mode, or not
+ const navbar_mode = this.element.dataset.navbarMode === "true";
+
+ const that = this;
+
+ const recentSearchesPlugin = createLocalStorageRecentSearchesPlugin({
+ key: 'RECENT_SEARCH',
+ limit: 5,
+ });
+
+ this._autocomplete = autocomplete({
+ container: this.element,
+ //Place the panel in the navbar, if the element is in navbar mode
+ panelContainer: navbar_mode ? document.getElementById("navbar-search-form") : document.body,
+ panelPlacement: this.element.dataset.panelPlacement,
+ plugins: [recentSearchesPlugin],
+ openOnFocus: true,
+ placeholder: trans(SEARCH_PLACEHOLDER),
+ translations: {
+ submitButtonTitle: trans(SEARCH_SUBMIT)
+ },
+
+ // Use a navigator compatible with turbo:
+ navigator: {
+ navigate({ itemUrl }) {
+ window.Turbo.visit(itemUrl, { action: "advance" });
+ },
+ navigateNewTab({ itemUrl }) {
+ const windowReference = window.open(itemUrl, '_blank', 'noopener');
+
+ if (windowReference) {
+ windowReference.focus();
+ }
+ },
+ navigateNewWindow({ itemUrl }) {
+ window.open(itemUrl, '_blank', 'noopener');
+ },
+ },
+
+ // If the form is submitted, forward the term to the form
+ onSubmit({state, event, ...setters}) {
+ //Put the current text into each target input field
+ const input = that.inputTarget;
+
+ if (!input) {
+ return;
+ }
+
+ //Do not submit the form, if the input is empty
+ if (state.query === "") {
+ return;
+ }
+
+ input.value = state.query;
+ input.form.requestSubmit();
+ },
+
+
+ getSources({ query }) {
+ return [
+ // The parts source
+ {
+ sourceId: 'parts',
+ getItems() {
+ const url = base_url.replace('__QUERY__', encodeURIComponent(query));
+
+ const data = fetch(url)
+ .then((response) => response.json())
+ ;
+
+ //Iterate over all fields besides the id and highlight them
+ const fields = ["name", "description", "category", "footprint"];
+
+ data.then((items) => {
+ items.forEach((item) => {
+ for (const field of fields) {
+ item[field] = that._highlight(item[field], query);
+ }
+ });
+ });
+
+ return data;
+ },
+ getItemUrl({ item }) {
+ return part_detail_uri_template.replace('__ID__', item.id);
+ },
+ templates: {
+ header({ html }) {
+ return html`${trans(STATISTICS_PARTS)}
+ `;
+ },
+ item({item, components, html}) {
+ const details_url = part_detail_uri_template.replace('__ID__', item.id);
+
+ return html`
+
+
+
+ `;
+ },
+ },
+ },
+ ];
+ },
+ });
+
+ //Try to find the input field and register a defocus handler. This is necessarry, as by default the autocomplete
+ //lib has problems when multiple inputs are present on the page. (see https://github.com/algolia/autocomplete/issues/1216)
+ const inputs = this.element.getElementsByClassName('aa-Input');
+ for (const input of inputs) {
+ input.addEventListener('blur', () => {
+ this._autocomplete.setIsOpen(false);
+ });
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/assets/controllers/elements/part_select_controller.js b/assets/controllers/elements/part_select_controller.js
index c7507636..5abd5ba3 100644
--- a/assets/controllers/elements/part_select_controller.js
+++ b/assets/controllers/elements/part_select_controller.js
@@ -27,7 +27,7 @@ export default class extends Controller {
}
let tmp = '
' +
- "
" +
+ "
" +
(data.image ? "" : "") +
"
" +
"
" +
diff --git a/assets/controllers/elements/password_strength_estimate_controller.js b/assets/controllers/elements/password_strength_estimate_controller.js
new file mode 100644
index 00000000..0fc9c578
--- /dev/null
+++ b/assets/controllers/elements/password_strength_estimate_controller.js
@@ -0,0 +1,123 @@
+/*
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+
+import {Controller} from "@hotwired/stimulus";
+import { zxcvbn, zxcvbnOptions } from '@zxcvbn-ts/core';
+import * as zxcvbnCommonPackage from '@zxcvbn-ts/language-common';
+import * as zxcvbnEnPackage from '@zxcvbn-ts/language-en';
+import * as zxcvbnDePackage from '@zxcvbn-ts/language-de';
+import * as zxcvbnFrPackage from '@zxcvbn-ts/language-fr';
+import * as zxcvbnJaPackage from '@zxcvbn-ts/language-ja';
+import {trans, USER_PASSWORD_STRENGTH_VERY_WEAK, USER_PASSWORD_STRENGTH_WEAK, USER_PASSWORD_STRENGTH_MEDIUM,
+ USER_PASSWORD_STRENGTH_STRONG, USER_PASSWORD_STRENGTH_VERY_STRONG} from '../../translator.js';
+
+/* stimulusFetch: 'lazy' */
+export default class extends Controller {
+
+ _passwordInput;
+
+ static targets = ["badge", "warning"]
+
+ _getTranslations() {
+ //Get the current locale
+ const locale = document.documentElement.lang;
+ if (locale.includes('de')) {
+ return zxcvbnDePackage.translations;
+ } else if (locale.includes('fr')) {
+ return zxcvbnFrPackage.translations;
+ } else if (locale.includes('ja')) {
+ return zxcvbnJaPackage.translations;
+ }
+
+ //Fallback to english
+ return zxcvbnEnPackage.translations;
+ }
+
+ connect() {
+ //Find the password input field
+ this._passwordInput = this.element.querySelector('input[type="password"]');
+
+ //Configure zxcvbn
+ const options = {
+ graphs: zxcvbnCommonPackage.adjacencyGraphs,
+ dictionary: {
+ ...zxcvbnCommonPackage.dictionary,
+ // We could use the english dictionary here too, but it is very big. So we just use the common words
+ //...zxcvbnEnPackage.dictionary,
+ },
+ translations: this._getTranslations(),
+ };
+ zxcvbnOptions.setOptions(options);
+
+ //Add event listener to the password input field
+ this._passwordInput.addEventListener('input', this._onPasswordInput.bind(this));
+ }
+
+ _onPasswordInput() {
+ //Retrieve the password
+ const password = this._passwordInput.value;
+
+ //Estimate the password strength
+ const result = zxcvbn(password);
+
+ //Update the badge
+ this.badgeTarget.parentElement.classList.remove("d-none");
+ this._setBadgeToLevel(result.score);
+
+ this.warningTarget.innerHTML = result.feedback.warning;
+ }
+
+ _setBadgeToLevel(level) {
+ let text, classes;
+
+ switch (level) {
+ case 0:
+ text = trans(USER_PASSWORD_STRENGTH_VERY_WEAK);
+ classes = "bg-danger badge-danger";
+ break;
+ case 1:
+ text = trans(USER_PASSWORD_STRENGTH_WEAK);
+ classes = "bg-warning badge-warning";
+ break;
+ case 2:
+ text = trans(USER_PASSWORD_STRENGTH_MEDIUM)
+ classes = "bg-info badge-info";
+ break;
+ case 3:
+ text = trans(USER_PASSWORD_STRENGTH_STRONG);
+ classes = "bg-primary badge-primary";
+ break;
+ case 4:
+ text = trans(USER_PASSWORD_STRENGTH_VERY_STRONG);
+ classes = "bg-success badge-success";
+ break;
+ default:
+ text = "???";
+ classes = "bg-secondary badge-secondary";
+ }
+
+ this.badgeTarget.innerHTML = text;
+ //Remove all classes
+ this.badgeTarget.className = '';
+ //Re-add the classes
+ this.badgeTarget.classList.add("badge");
+ this.badgeTarget.classList.add(...classes.split(" "));
+ }
+}
\ No newline at end of file
diff --git a/assets/controllers/elements/static_file_autocomplete_controller.js b/assets/controllers/elements/static_file_autocomplete_controller.js
new file mode 100644
index 00000000..31ca0314
--- /dev/null
+++ b/assets/controllers/elements/static_file_autocomplete_controller.js
@@ -0,0 +1,106 @@
+/*
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import {Controller} from "@hotwired/stimulus";
+
+import "tom-select/dist/css/tom-select.bootstrap5.css";
+import '../../css/components/tom-select_extensions.css';
+import TomSelect from "tom-select";
+
+import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
+import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
+
+TomSelect.define('click_to_edit', TomSelect_click_to_edit)
+TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
+
+/**
+ * This is the frontend controller for StaticFileAutocompleteType form element.
+ * Basically it loads a text file from the given url (via data-url) and uses it as a source for the autocomplete.
+ * The file is just a list of strings, one per line, which will be used as the autocomplete options.
+ * Lines starting with # will be ignored.
+ */
+export default class extends Controller {
+ _tomSelect;
+
+ connect() {
+
+ let settings = {
+ persistent: false,
+ create: true,
+ maxItems: 1,
+ maxOptions: 100,
+ createOnBlur: true,
+ selectOnTab: true,
+ valueField: 'text',
+ searchField: 'text',
+ orderField: 'text',
+
+ //This a an ugly solution to disable the delimiter parsing of the TomSelect plugin
+ delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING',
+ plugins: {
+ 'autoselect_typed': {},
+ 'click_to_edit': {},
+ 'clear_button': {},
+ 'restore_on_backspace': {}
+ }
+ };
+
+ if (this.element.dataset.url) {
+ const url = this.element.dataset.url;
+ settings.load = (query, callback) => {
+ const self = this;
+ if (self.loading > 1) {
+ callback();
+ return;
+ }
+
+ fetch(url)
+ .then(response => response.text())
+ .then(text => {
+ // Convert the text file to array
+ let lines = text.split("\n");
+ //Remove all lines beginning with #
+ lines = lines.filter(x => !x.startsWith("#"));
+
+ //Convert the array to an object, where each line is in the text field
+ lines = lines.map(x => {
+ return {text: x};
+ });
+
+
+ //Unset the load function to prevent endless recursion
+ self._tomSelect.settings.load = null;
+
+ callback(lines);
+ }).catch(() => {
+ callback();
+ });
+ };
+ }
+
+ this._tomSelect = new TomSelect(this.element, settings);
+ }
+
+ disconnect() {
+ super.disconnect();
+ //Destroy the TomSelect instance
+ this._tomSelect.destroy();
+ }
+
+}
diff --git a/assets/controllers/elements/structural_entity_select_controller.js b/assets/controllers/elements/structural_entity_select_controller.js
index 38480cfa..a1114a97 100644
--- a/assets/controllers/elements/structural_entity_select_controller.js
+++ b/assets/controllers/elements/structural_entity_select_controller.js
@@ -22,6 +22,10 @@ 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'
+
+import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
+TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
export default class extends Controller {
_tomSelect;
@@ -36,12 +40,20 @@ export default class extends Controller {
const allowAdd = this.element.getAttribute("data-allow-add") === "true";
const addHint = this.element.getAttribute("data-add-hint") ?? "";
+
+
+
let settings = {
allowEmptyOption: true,
selectOnTab: true,
maxOptions: null,
- create: allowAdd,
- createFilter: /\D/, //Must contain a non-digit character, otherwise they would be recognized as DB ID
+ create: allowAdd ? this.createItem.bind(this) : false,
+ createFilter: this.createFilter.bind(this),
+
+ // This three options allow us to paste element names with commas: (see issue #538)
+ maxItems: 1,
+ delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$",
+ splitOn: null,
searchField: [
{field: "text", weight : 2},
@@ -52,15 +64,108 @@ export default class extends Controller {
render: {
item: this.renderItem.bind(this),
option: this.renderOption.bind(this),
- option_create: function(data, escape) {
+ option_create: (data, escape) => {
+ //If the input starts with "->", we prepend the current selected value, for easier extension of existing values
+ //This here handles the display part, while the createItem function handles the actual creation
+ if (data.input.startsWith("->")) {
+ //Get current selected value
+ const current = this._tomSelect.getItem(this._tomSelect.getValue()).textContent.replaceAll("→", "->").trim();
+ //Prepend it to the input
+ if (current) {
+ data.input = current + " " + data.input;
+ } else {
+ //If there is no current value, we remove the "->"
+ data.input = data.input.substring(2);
+ }
+ }
+
return '
';
},
+ },
+
+ //Add callbacks to update validity
+ onInitialize: this.updateValidity.bind(this),
+ onChange: this.updateValidity.bind(this),
+
+ plugins: {
+ "autoselect_typed": {},
}
};
+ //Add clear button plugin, if an empty option is present
+ if (this.element.querySelector("option[value='']") !== null) {
+ settings.plugins["clear_button"] = {};
+ }
+
this._tomSelect = new TomSelect(this.element, settings);
+ //Do not do a sync here as this breaks the initial rendering of the empty option
+ //this._tomSelect.sync();
+ }
+
+ createItem(input, callback) {
+
+ //If the input starts with "->", we prepend the current selected value, for easier extension of existing values
+ if (input.startsWith("->")) {
+ //Get current selected value
+ let current = this._tomSelect.getItem(this._tomSelect.getValue()).textContent.replaceAll("→", "->").trim();
+ //Replace no break spaces with normal spaces
+ current = current.replaceAll("\u00A0", " ");
+ //Prepend it to the input
+ if (current) {
+ input = current + " " + input;
+ } else {
+ //If there is no current value, we remove the "->"
+ input = input.substring(2);
+ }
+ }
+
+ callback({
+ //$%$ is a special value prefix, that is used to identify items, that are not yet in the DB
+ value: '$%$' + input,
+ text: input,
+ not_in_db_yet: true,
+ });
+ }
+
+ createFilter(input) {
+
+ //Normalize the input (replace spacing around arrows)
+ if (input.includes("->")) {
+ const inputs = input.split("->");
+ inputs.forEach((value, index) => {
+ inputs[index] = value.trim();
+ });
+ input = inputs.join("->");
+ } else {
+ input = input.trim();
+ }
+
+ const options = this._tomSelect.options;
+ //Iterate over all options and check if the input is already present
+ for (let index in options) {
+ const option = options[index];
+ if (option.path === input) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+
+ updateValidity() {
+ //Mark this input as invalid, if the selected option is disabled
+
+ const input = this.element;
+ const selectedOption = input.options[input.selectedIndex];
+
+ if (selectedOption && selectedOption.disabled) {
+ input.setCustomValidity("This option was disabled. Please select another option.");
+ } else {
+ input.setCustomValidity("");
+ }
}
getTomSelect() {
@@ -78,14 +183,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 '
';
}
}
diff --git a/src/DataTables/Column/RowClassColumn.php b/src/DataTables/Column/RowClassColumn.php
index 4ac61c02..15bf8bf2 100644
--- a/src/DataTables/Column/RowClassColumn.php
+++ b/src/DataTables/Column/RowClassColumn.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Column;
use Omines\DataTablesBundle\Column\AbstractColumn;
@@ -26,8 +28,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class RowClassColumn extends AbstractColumn
{
-
- public function configureOptions(OptionsResolver $resolver)
+ public function configureOptions(OptionsResolver $resolver): static
{
parent::configureOptions($resolver);
@@ -42,14 +43,17 @@ class RowClassColumn extends AbstractColumn
return $this;
}
- public function initialize(string $name, int $index, array $options, DataTable $dataTable)
+ public function initialize(string $name, int $index, array $options, DataTable $dataTable): void
{
//The field name is always "$$rowClass" as this is the name the frontend controller expects
parent::initialize('$$rowClass', $index, $options, $dataTable); // TODO: Change the autogenerated stub
}
- public function normalize($value)
+ /**
+ * @return mixed
+ */
+ public function normalize($value): mixed
{
return $value;
}
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Column/SIUnitNumberColumn.php b/src/DataTables/Column/SIUnitNumberColumn.php
index a66bc868..b64152be 100644
--- a/src/DataTables/Column/SIUnitNumberColumn.php
+++ b/src/DataTables/Column/SIUnitNumberColumn.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Column;
use App\Services\Formatters\SIFormatter;
@@ -26,14 +28,11 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class SIUnitNumberColumn extends AbstractColumn
{
- protected SIFormatter $formatter;
-
- public function __construct(SIFormatter $formatter)
+ public function __construct(protected SIFormatter $formatter)
{
- $this->formatter = $formatter;
}
- public function configureOptions(OptionsResolver $resolver)
+ public function configureOptions(OptionsResolver $resolver): static
{
parent::configureOptions($resolver);
@@ -43,13 +42,13 @@ class SIUnitNumberColumn extends AbstractColumn
return $this;
}
- public function normalize($value)
+ public function normalize($value): string
{
//Ignore null values
if ($value === null) {
return '';
}
- return $this->formatter->format((float) $value, $this->options['unit'], $this->options['precision']);
+ return htmlspecialchars($this->formatter->format((float) $value, $this->options['unit'], $this->options['precision']));
}
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Column/SelectColumn.php b/src/DataTables/Column/SelectColumn.php
index 218f022d..39445ac8 100644
--- a/src/DataTables/Column/SelectColumn.php
+++ b/src/DataTables/Column/SelectColumn.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Column;
use Omines\DataTablesBundle\Column\AbstractColumn;
@@ -28,7 +30,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
class SelectColumn extends AbstractColumn
{
- public function configureOptions(OptionsResolver $resolver)
+ public function configureOptions(OptionsResolver $resolver): static
{
parent::configureOptions($resolver);
@@ -36,21 +38,24 @@ class SelectColumn extends AbstractColumn
'label' => '',
'orderable' => false,
'searchable' => false,
- 'className' => 'select-checkbox no-colvis',
+ 'className' => 'dt-select no-colvis',
'visible' => true,
]);
return $this;
}
- public function normalize($value)
+ /**
+ * @return mixed
+ */
+ public function normalize($value): mixed
{
return $value;
}
- public function render($value, $context)
+ public function render($value, $context): string
{
//Return empty string, as it this column is filled by datatables on client side
return '';
}
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Column/TagsColumn.php b/src/DataTables/Column/TagsColumn.php
index 4d0dee0a..f98a3900 100644
--- a/src/DataTables/Column/TagsColumn.php
+++ b/src/DataTables/Column/TagsColumn.php
@@ -27,11 +27,8 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class TagsColumn extends AbstractColumn
{
- protected UrlGeneratorInterface $urlGenerator;
-
- public function __construct(UrlGeneratorInterface $urlGenerator)
+ public function __construct(protected UrlGeneratorInterface $urlGenerator)
{
- $this->urlGenerator = $urlGenerator;
}
/**
@@ -40,17 +37,21 @@ class TagsColumn extends AbstractColumn
* @param mixed $value The single value of the column
* @return mixed
*/
- public function normalize($value)
+ public function normalize(mixed $value): mixed
{
if (empty($value)) {
return [];
}
- return explode(',', $value);
+ return explode(',', (string) $value);
}
public function render($tags, $context): string
{
+ if (!is_iterable($tags)) {
+ throw new \LogicException('TagsColumn::render() expects an iterable');
+ }
+
$html = '';
$count = 10;
foreach ($tags as $tag) {
@@ -61,7 +62,7 @@ class TagsColumn extends AbstractColumn
$html .= sprintf(
'%s',
$this->urlGenerator->generate('part_list_tags', ['tag' => $tag]),
- htmlspecialchars($tag)
+ htmlspecialchars((string) $tag)
);
}
diff --git a/src/DataTables/ErrorDataTable.php b/src/DataTables/ErrorDataTable.php
new file mode 100644
index 00000000..833ea934
--- /dev/null
+++ b/src/DataTables/ErrorDataTable.php
@@ -0,0 +1,87 @@
+.
+ */
+namespace App\DataTables;
+
+use App\DataTables\Column\RowClassColumn;
+use Omines\DataTablesBundle\Adapter\ArrayAdapter;
+use Omines\DataTablesBundle\Column\TextColumn;
+use Omines\DataTablesBundle\DataTable;
+use Omines\DataTablesBundle\DataTableFactory;
+use Omines\DataTablesBundle\DataTableTypeInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class ErrorDataTable implements DataTableTypeInterface
+{
+ public function configureOptions(OptionsResolver $optionsResolver): void
+ {
+ $optionsResolver->setRequired('errors');
+ $optionsResolver->setAllowedTypes('errors', ['array', 'string']);
+ $optionsResolver->setNormalizer('errors', function (OptionsResolver $optionsResolver, $errors) {
+ if (is_string($errors)) {
+ $errors = [$errors];
+ }
+
+ return $errors;
+ });
+ }
+
+ public function configure(DataTable $dataTable, array $options): void
+ {
+ $optionsResolver = new OptionsResolver();
+ $this->configureOptions($optionsResolver);
+ $options = $optionsResolver->resolve($options);
+
+ $dataTable
+ ->add('dont_matter_we_only_set_color', RowClassColumn::class, [
+ 'render' => fn($value, $context): string => 'table-warning',
+ ])
+
+ ->add('error', TextColumn::class, [
+ 'label' => 'error_table.error',
+ 'render' => fn($value, $context): string => ' ' . $value,
+ ])
+ ;
+
+ //Build the array containing data
+ $data = [];
+ $n = 0;
+ foreach ($options['errors'] as $error) {
+ $data['error_' . $n] = ['error' => $error];
+ $n++;
+ }
+
+ $dataTable->createAdapter(ArrayAdapter::class, $data);
+ }
+
+ /**
+ * @param string[]|string $errors
+ */
+ public static function errorTable(DataTableFactory $dataTableFactory, Request $request, array|string $errors): Response
+ {
+ $error_table = $dataTableFactory->createFromType(self::class, ['errors' => $errors]);
+ $error_table->handleRequest($request);
+ return $error_table->getResponse();
+ }
+}
diff --git a/src/DataTables/Filters/AttachmentFilter.php b/src/DataTables/Filters/AttachmentFilter.php
index 9325bd60..d41bbe39 100644
--- a/src/DataTables/Filters/AttachmentFilter.php
+++ b/src/DataTables/Filters/AttachmentFilter.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\BooleanConstraint;
@@ -35,13 +37,16 @@ class AttachmentFilter implements FilterInterface
{
use CompoundFilterTrait;
- protected NumberConstraint $dbId;
- protected InstanceOfConstraint $targetType;
- protected TextConstraint $name;
- protected EntityConstraint $attachmentType;
- protected BooleanConstraint $showInTable;
- protected DateTimeConstraint $lastModified;
- protected DateTimeConstraint $addedDate;
+ public readonly NumberConstraint $dbId;
+ public readonly InstanceOfConstraint $targetType;
+ public readonly TextConstraint $name;
+ public readonly EntityConstraint $attachmentType;
+ public readonly BooleanConstraint $showInTable;
+ public readonly DateTimeConstraint $lastModified;
+ public readonly DateTimeConstraint $addedDate;
+
+ public readonly TextConstraint $originalFileName;
+ public readonly TextConstraint $externalLink;
public function __construct(NodesListBuilder $nodesListBuilder)
@@ -53,74 +58,13 @@ class AttachmentFilter implements FilterInterface
$this->lastModified = new DateTimeConstraint('attachment.lastModified');
$this->addedDate = new DateTimeConstraint('attachment.addedDate');
$this->showInTable = new BooleanConstraint('attachment.show_in_table');
+ $this->originalFileName = new TextConstraint('attachment.original_filename');
+ $this->externalLink = new TextConstraint('attachment.external_path');
+
}
public function apply(QueryBuilder $queryBuilder): void
{
$this->applyAllChildFilters($queryBuilder);
}
-
- /**
- * @return NumberConstraint
- */
- public function getDbId(): NumberConstraint
- {
- return $this->dbId;
- }
-
- /**
- * @return TextConstraint
- */
- public function getName(): TextConstraint
- {
- return $this->name;
- }
-
- /**
- * @return DateTimeConstraint
- */
- public function getLastModified(): DateTimeConstraint
- {
- return $this->lastModified;
- }
-
- /**
- * @return DateTimeConstraint
- */
- public function getAddedDate(): DateTimeConstraint
- {
- return $this->addedDate;
- }
-
-
- /**
- * @return BooleanConstraint
- */
- public function getShowInTable(): BooleanConstraint
- {
- return $this->showInTable;
- }
-
-
- /**
- * @return EntityConstraint
- */
- public function getAttachmentType(): EntityConstraint
- {
- return $this->attachmentType;
- }
-
- /**
- * @return InstanceOfConstraint
- */
- public function getTargetType(): InstanceOfConstraint
- {
- return $this->targetType;
- }
-
-
-
-
-
-
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Filters/CompoundFilterTrait.php b/src/DataTables/Filters/CompoundFilterTrait.php
index ba778aac..5e722841 100644
--- a/src/DataTables/Filters/CompoundFilterTrait.php
+++ b/src/DataTables/Filters/CompoundFilterTrait.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters;
use Doctrine\Common\Collections\Collection;
@@ -37,9 +39,6 @@ trait CompoundFilterTrait
$reflection = new \ReflectionClass($this);
foreach ($reflection->getProperties() as $property) {
- //Set property to accessible (otherwise we run into problems on PHP < 8.1)
- $property->setAccessible(true);
-
$value = $property->getValue($this);
//We only want filters (objects implementing FilterInterface)
if($value instanceof FilterInterface) {
@@ -60,8 +59,6 @@ trait CompoundFilterTrait
/**
* Applies all children filters that are declared as property of this filter using reflection.
- * @param QueryBuilder $queryBuilder
- * @return void
*/
protected function applyAllChildFilters(QueryBuilder $queryBuilder): void
{
@@ -72,4 +69,4 @@ trait CompoundFilterTrait
$filter->apply($queryBuilder);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Filters/Constraints/AbstractConstraint.php b/src/DataTables/Filters/Constraints/AbstractConstraint.php
index 0a888214..7f16511e 100644
--- a/src/DataTables/Filters/Constraints/AbstractConstraint.php
+++ b/src/DataTables/Filters/Constraints/AbstractConstraint.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters\Constraints;
use App\DataTables\Filters\FilterInterface;
-use Doctrine\ORM\QueryBuilder;
abstract class AbstractConstraint implements FilterInterface
{
use FilterTrait;
- /**
- * @var string The property where this BooleanConstraint should apply to
- */
- protected string $property;
-
/**
* @var string
*/
@@ -44,9 +40,13 @@ abstract class AbstractConstraint implements FilterInterface
*/
abstract public function isEnabled(): bool;
- public function __construct(string $property, string $identifier = null)
+ public function __construct(
+ /**
+ * @var string The property where this BooleanConstraint should apply to
+ */
+ protected string $property,
+ ?string $identifier = null)
{
- $this->property = $property;
$this->identifier = $identifier ?? $this->generateParameterIdentifier($property);
}
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Filters/Constraints/BooleanConstraint.php b/src/DataTables/Filters/Constraints/BooleanConstraint.php
index b180f6aa..8eb4f042 100644
--- a/src/DataTables/Filters/Constraints/BooleanConstraint.php
+++ b/src/DataTables/Filters/Constraints/BooleanConstraint.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters\Constraints;
-use App\DataTables\Filters\FilterInterface;
use Doctrine\ORM\QueryBuilder;
class BooleanConstraint extends AbstractConstraint
{
- /** @var bool|null The value of our constraint */
- protected ?bool $value;
-
-
- public function __construct(string $property, string $identifier = null, ?bool $default_value = null)
+ public function __construct(
+ string $property,
+ ?string $identifier = null,
+ /** @var bool|null The value of our constraint */
+ protected ?bool $value = null
+ )
{
parent::__construct($property, $identifier);
- $this->value = $default_value;
}
/**
* Gets the value of this constraint. Null means "don't filter", true means "filter for true", false means "filter for false".
- * @return bool|null
*/
public function getValue(): ?bool
{
@@ -46,7 +46,6 @@ class BooleanConstraint extends AbstractConstraint
/**
* Sets the value of this constraint. Null means "don't filter", true means "filter for true", false means "filter for false".
- * @param bool|null $value
*/
public function setValue(?bool $value): void
{
@@ -68,4 +67,4 @@ class BooleanConstraint extends AbstractConstraint
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, '=', $this->value);
}
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Filters/Constraints/ChoiceConstraint.php b/src/DataTables/Filters/Constraints/ChoiceConstraint.php
index 52c0739f..cce7ce2c 100644
--- a/src/DataTables/Filters/Constraints/ChoiceConstraint.php
+++ b/src/DataTables/Filters/Constraints/ChoiceConstraint.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters\Constraints;
use Doctrine\ORM\QueryBuilder;
class ChoiceConstraint extends AbstractConstraint
{
- public const ALLOWED_OPERATOR_VALUES = ['ANY', 'NONE'];
+ final public const ALLOWED_OPERATOR_VALUES = ['ANY', 'NONE'];
/**
* @var string[]|int[] The values to compare to
*/
- protected array $value;
+ protected array $value = [];
/**
* @var string The operator to use
*/
- protected string $operator;
+ protected string $operator = "";
/**
* @return string[]|int[]
@@ -46,7 +48,6 @@ class ChoiceConstraint extends AbstractConstraint
/**
* @param string[]|int[] $value
- * @return ChoiceConstraint
*/
public function setValue(array $value): ChoiceConstraint
{
@@ -54,18 +55,11 @@ class ChoiceConstraint extends AbstractConstraint
return $this;
}
- /**
- * @return string
- */
public function getOperator(): string
{
return $this->operator;
}
- /**
- * @param string $operator
- * @return ChoiceConstraint
- */
public function setOperator(string $operator): ChoiceConstraint
{
$this->operator = $operator;
@@ -76,7 +70,7 @@ class ChoiceConstraint extends AbstractConstraint
public function isEnabled(): bool
{
- return !empty($this->operator);
+ return $this->operator !== '' && count($this->value) > 0;
}
public function apply(QueryBuilder $queryBuilder): void
@@ -99,4 +93,4 @@ class ChoiceConstraint extends AbstractConstraint
throw new \RuntimeException('Unknown operator '. $this->operator . ' provided. Valid operators are '. implode(', ', self::ALLOWED_OPERATOR_VALUES));
}
}
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Filters/Constraints/DateTimeConstraint.php b/src/DataTables/Filters/Constraints/DateTimeConstraint.php
index 39eaaa1d..a3043170 100644
--- a/src/DataTables/Filters/Constraints/DateTimeConstraint.php
+++ b/src/DataTables/Filters/Constraints/DateTimeConstraint.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters\Constraints;
+use Doctrine\ORM\QueryBuilder;
+use RuntimeException;
+
/**
- * An alias of NumberConstraint to use to filter on a DateTime
+ * Similar to NumberConstraint but for DateTime values
*/
-class DateTimeConstraint extends NumberConstraint
+class DateTimeConstraint extends AbstractConstraint
{
-}
\ No newline at end of file
+ protected const ALLOWED_OPERATOR_VALUES = ['=', '!=', '<', '>', '<=', '>=', 'BETWEEN'];
+
+ public function __construct(
+ string $property,
+ ?string $identifier = null,
+ /**
+ * The value1 used for comparison (this is the main one used for all mono-value comparisons)
+ */
+ protected \DateTimeInterface|null $value1 = null,
+ protected ?string $operator = null,
+ /**
+ * The second value used when operator is RANGE; this is the upper bound of the range
+ */
+ protected \DateTimeInterface|null $value2 = null)
+ {
+ parent::__construct($property, $identifier);
+ }
+
+ public function getValue1(): ?\DateTimeInterface
+ {
+ return $this->value1;
+ }
+
+ public function setValue1(\DateTimeInterface|null $value1): void
+ {
+ $this->value1 = $value1;
+ }
+
+ public function getValue2(): ?\DateTimeInterface
+ {
+ return $this->value2;
+ }
+
+ public function setValue2(?\DateTimeInterface $value2): void
+ {
+ $this->value2 = $value2;
+ }
+
+ public function getOperator(): string|null
+ {
+ return $this->operator;
+ }
+
+ /**
+ * @param string $operator
+ */
+ public function setOperator(?string $operator): void
+ {
+ $this->operator = $operator;
+ }
+
+ public function isEnabled(): bool
+ {
+ return $this->value1 !== null
+ && ($this->operator !== null && $this->operator !== '');
+ }
+
+ public function apply(QueryBuilder $queryBuilder): void
+ {
+ //If no value is provided then we do not apply a filter
+ if (!$this->isEnabled()) {
+ return;
+ }
+
+ //Ensure we have an valid operator
+ if(!in_array($this->operator, self::ALLOWED_OPERATOR_VALUES, true)) {
+ throw new \RuntimeException('Invalid operator '. $this->operator . ' provided. Valid operators are '. implode(', ', self::ALLOWED_OPERATOR_VALUES));
+ }
+
+ if ($this->operator !== 'BETWEEN') {
+ $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, $this->operator, $this->value1);
+ } else {
+ if ($this->value2 === null) {
+ throw new RuntimeException("Cannot use operator BETWEEN without value2!");
+ }
+
+ $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier . '1', '>=', $this->value1);
+ $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier . '2', '<=', $this->value2);
+ }
+ }
+}
diff --git a/src/DataTables/Filters/Constraints/EntityConstraint.php b/src/DataTables/Filters/Constraints/EntityConstraint.php
index facbbfea..c75da80d 100644
--- a/src/DataTables/Filters/Constraints/EntityConstraint.php
+++ b/src/DataTables/Filters/Constraints/EntityConstraint.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters\Constraints;
use App\Entity\Base\AbstractDBElement;
@@ -33,46 +35,26 @@ class EntityConstraint extends AbstractConstraint
private const ALLOWED_OPERATOR_VALUES_BASE = ['=', '!='];
private const ALLOWED_OPERATOR_VALUES_STRUCTURAL = ['INCLUDING_CHILDREN', 'EXCLUDING_CHILDREN'];
- /**
- * @var NodesListBuilder
- */
- protected ?NodesListBuilder $nodesListBuilder;
-
- /**
- * @var class-string The class to use for the comparison
- */
- protected string $class;
-
- /**
- * @var string|null The operator to use
- */
- protected ?string $operator;
-
- /**
- * @var T The value to compare to
- */
- protected $value;
-
/**
* @param NodesListBuilder|null $nodesListBuilder
* @param class-string $class
* @param string $property
* @param string|null $identifier
- * @param null $value
- * @param string $operator
+ * @param T|null $value
+ * @param string|null $operator
*/
- public function __construct(?NodesListBuilder $nodesListBuilder, string $class, string $property, string $identifier = null, $value = null, string $operator = '')
+ public function __construct(protected ?NodesListBuilder $nodesListBuilder,
+ protected string $class,
+ string $property,
+ ?string $identifier = null,
+ protected ?AbstractDBElement $value = null,
+ protected ?string $operator = null)
{
- $this->nodesListBuilder = $nodesListBuilder;
- $this->class = $class;
-
- if ($nodesListBuilder === null && $this->isStructural()) {
+ if (!$nodesListBuilder instanceof NodesListBuilder && $this->isStructural()) {
throw new \InvalidArgumentException('NodesListBuilder must be provided for structural entities');
}
parent::__construct($property, $identifier);
- $this->value = $value;
- $this->operator = $operator;
}
public function getClass(): string
@@ -80,17 +62,11 @@ class EntityConstraint extends AbstractConstraint
return $this->class;
}
- /**
- * @return string|null
- */
public function getOperator(): ?string
{
return $this->operator;
}
- /**
- * @param string|null $operator
- */
public function setOperator(?string $operator): self
{
$this->operator = $operator;
@@ -106,9 +82,10 @@ class EntityConstraint extends AbstractConstraint
}
/**
- * @param T|null $value
+ * @param AbstractDBElement|null $value
+ * @phpstan-param T|null $value
*/
- public function setValue(?AbstractDBElement $value): void
+ public function setValue(AbstractDBElement|null $value): void
{
if (!$value instanceof $this->class) {
throw new \InvalidArgumentException('The value must be an instance of ' . $this->class);
@@ -119,7 +96,7 @@ class EntityConstraint extends AbstractConstraint
/**
* Checks whether the constraints apply to a structural type or not
- * @return bool
+ * @phpstan-assert-if-true AbstractStructuralDBElement $this->value
*/
public function isStructural(): bool
{
@@ -136,7 +113,7 @@ class EntityConstraint extends AbstractConstraint
$tmp = self::ALLOWED_OPERATOR_VALUES_BASE;
if ($this->isStructural()) {
- $tmp = array_merge($tmp, self::ALLOWED_OPERATOR_VALUES_STRUCTURAL);
+ $tmp = [...$tmp, ...self::ALLOWED_OPERATOR_VALUES_STRUCTURAL];
}
return $tmp;
@@ -144,7 +121,7 @@ class EntityConstraint extends AbstractConstraint
public function isEnabled(): bool
{
- return !empty($this->operator);
+ return $this->operator !== null && $this->operator !== '';
}
public function apply(QueryBuilder $queryBuilder): void
@@ -175,8 +152,9 @@ class EntityConstraint extends AbstractConstraint
}
if($this->operator === '=' || $this->operator === '!=') {
- $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, $this->operator, $this->value);
- return;
+ //Include null values on != operator, so that really all values are returned that are not equal to the given value
+ $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, $this->operator, $this->value, $this->operator === '!=');
+ return;
}
//Otherwise retrieve the children list and apply the operator to it
@@ -191,7 +169,8 @@ class EntityConstraint extends AbstractConstraint
}
if ($this->operator === 'EXCLUDING_CHILDREN') {
- $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, 'NOT IN', $list);
+ //Include null values in the result, so that all elements that are not in the list are returned
+ $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, 'NOT IN', $list, true);
return;
}
} else {
@@ -199,4 +178,4 @@ class EntityConstraint extends AbstractConstraint
}
}
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Filters/Constraints/FilterTrait.php b/src/DataTables/Filters/Constraints/FilterTrait.php
index 988890ba..3260e4e3 100644
--- a/src/DataTables/Filters/Constraints/FilterTrait.php
+++ b/src/DataTables/Filters/Constraints/FilterTrait.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters\Constraints;
-use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\QueryBuilder;
trait FilterTrait
@@ -28,7 +29,7 @@ trait FilterTrait
protected bool $useHaving = false;
- public function useHaving($value = true): self
+ public function useHaving($value = true): static
{
$this->useHaving = $value;
return $this;
@@ -36,7 +37,6 @@ trait FilterTrait
/**
* Checks if the given input is an aggregateFunction like COUNT(part.partsLot) or so
- * @return bool
*/
protected function isAggregateFunctionString(string $input): bool
{
@@ -45,26 +45,25 @@ trait FilterTrait
/**
* Generates a parameter identifier that can be used for the given property. It gives random results, to be unique, so you have to cache it.
- * @param string $property
- * @return string
*/
protected function generateParameterIdentifier(string $property): string
{
//Replace all special characters with underscores
- $property = preg_replace('/[^a-zA-Z0-9_]/', '_', $property);
+ $property = preg_replace('/\W/', '_', $property);
//Add a random number to the end of the property name for uniqueness
return $property . '_' . uniqid("", false);
}
/**
* Adds a simple constraint in the form of (property OPERATOR value) (e.g. "part.name = :name") to the given query builder.
- * @param QueryBuilder $queryBuilder
- * @param string $property
- * @param string $comparison_operator
- * @param mixed $value
- * @return void
+ * @param QueryBuilder $queryBuilder The query builder to add the constraint to
+ * @param string $property The property to compare
+ * @param string $parameterIdentifier The identifier for the parameter
+ * @param string $comparison_operator The comparison operator to use
+ * @param mixed $value The value to compare to
+ * @param bool $include_null If true, the result of this constraint will also include null values of this property (useful for exclusion filters)
*/
- protected function addSimpleAndConstraint(QueryBuilder $queryBuilder, string $property, string $parameterIdentifier, string $comparison_operator, $value): void
+ protected function addSimpleAndConstraint(QueryBuilder $queryBuilder, string $property, string $parameterIdentifier, string $comparison_operator, mixed $value, bool $include_null = false): void
{
if ($comparison_operator === 'IN' || $comparison_operator === 'NOT IN') {
$expression = sprintf("%s %s (:%s)", $property, $comparison_operator, $parameterIdentifier);
@@ -72,6 +71,10 @@ trait FilterTrait
$expression = sprintf("%s %s :%s", $property, $comparison_operator, $parameterIdentifier);
}
+ if ($include_null) {
+ $expression = sprintf("(%s OR %s IS NULL)", $expression, $property);
+ }
+
if($this->useHaving || $this->isAggregateFunctionString($property)) { //If the property is an aggregate function, we have to use the "having" instead of the "where"
$queryBuilder->andHaving($expression);
} else {
@@ -80,4 +83,4 @@ trait FilterTrait
$queryBuilder->setParameter($parameterIdentifier, $value);
}
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Filters/Constraints/InstanceOfConstraint.php b/src/DataTables/Filters/Constraints/InstanceOfConstraint.php
index 6efe8cce..7fc242d7 100644
--- a/src/DataTables/Filters/Constraints/InstanceOfConstraint.php
+++ b/src/DataTables/Filters/Constraints/InstanceOfConstraint.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters\Constraints;
use Doctrine\ORM\QueryBuilder;
@@ -27,17 +29,17 @@ use Doctrine\ORM\QueryBuilder;
*/
class InstanceOfConstraint extends AbstractConstraint
{
- public const ALLOWED_OPERATOR_VALUES = ['ANY', 'NONE'];
+ final public const ALLOWED_OPERATOR_VALUES = ['ANY', 'NONE'];
/**
* @var string[] The values to compare to (fully qualified class names)
*/
- protected array $value;
+ protected array $value = [];
/**
* @var string The operator to use
*/
- protected string $operator;
+ protected string $operator = "";
/**
* @return string[]
@@ -57,16 +59,12 @@ class InstanceOfConstraint extends AbstractConstraint
return $this;
}
- /**
- * @return string
- */
public function getOperator(): string
{
return $this->operator;
}
/**
- * @param string $operator
* @return $this
*/
public function setOperator(string $operator): self
@@ -79,7 +77,7 @@ class InstanceOfConstraint extends AbstractConstraint
public function isEnabled(): bool
{
- return !empty($this->operator);
+ return $this->operator !== '' && count($this->value) > 0;
}
public function apply(QueryBuilder $queryBuilder): void
@@ -96,9 +94,10 @@ class InstanceOfConstraint extends AbstractConstraint
$expressions = [];
+ /** @phpstan-ignore-next-line */
if ($this->operator === 'ANY' || $this->operator === 'NONE') {
foreach($this->value as $value) {
- //We cannnot use an paramater here, as this is the only way to pass the FCQN to the query (via binded params, we would need to use ClassMetaData). See: https://github.com/doctrine/orm/issues/4462
+ //We can not use a parameter here, as this is the only way to pass the FCQN to the query (via binded params, we would need to use ClassMetaData). See: https://github.com/doctrine/orm/issues/4462
$expressions[] = ($queryBuilder->expr()->isInstanceOf($this->property, $value));
}
@@ -111,4 +110,4 @@ class InstanceOfConstraint extends AbstractConstraint
throw new \RuntimeException('Unknown operator '. $this->operator . ' provided. Valid operators are '. implode(', ', self::ALLOWED_OPERATOR_VALUES));
}
}
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Filters/Constraints/IntConstraint.php b/src/DataTables/Filters/Constraints/IntConstraint.php
index 601f6aa8..3fc5cce5 100644
--- a/src/DataTables/Filters/Constraints/IntConstraint.php
+++ b/src/DataTables/Filters/Constraints/IntConstraint.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters\Constraints;
use Doctrine\ORM\QueryBuilder;
@@ -35,4 +37,4 @@ class IntConstraint extends NumberConstraint
parent::apply($queryBuilder);
}
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Filters/Constraints/NumberConstraint.php b/src/DataTables/Filters/Constraints/NumberConstraint.php
index e8382723..dc7cf733 100644
--- a/src/DataTables/Filters/Constraints/NumberConstraint.php
+++ b/src/DataTables/Filters/Constraints/NumberConstraint.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters\Constraints;
-use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\QueryBuilder;
use RuntimeException;
class NumberConstraint extends AbstractConstraint
{
- public const ALLOWED_OPERATOR_VALUES = ['=', '!=', '<', '>', '<=', '>=', 'BETWEEN'];
+ protected const ALLOWED_OPERATOR_VALUES = ['=', '!=', '<', '>', '<=', '>=', 'BETWEEN'];
+ public function __construct(
+ string $property,
+ ?string $identifier = null,
+ /**
+ * The value1 used for comparison (this is the main one used for all mono-value comparisons)
+ */
+ protected float|int|null $value1 = null,
+ protected ?string $operator = null,
+ /**
+ * The second value used when operator is RANGE; this is the upper bound of the range
+ */
+ protected float|int|null $value2 = null)
+ {
+ parent::__construct($property, $identifier);
+ }
- /**
- * The value1 used for comparison (this is the main one used for all mono-value comparisons)
- * @var float|null|int|\DateTimeInterface
- */
- protected $value1;
-
- /**
- * The second value used when operator is RANGE; this is the upper bound of the range
- * @var float|null|int|\DateTimeInterface
- */
- protected $value2;
-
- /**
- * @var string The operator to use
- */
- protected ?string $operator;
-
- /**
- * @return float|int|null|\DateTimeInterface
- */
- public function getValue1()
+ public function getValue1(): float|int|null
{
return $this->value1;
}
- /**
- * @param float|int|\DateTimeInterface|null $value1
- */
- public function setValue1($value1): void
+ public function setValue1(float|int|null $value1): void
{
$this->value1 = $value1;
}
- /**
- * @return float|int|null
- */
- public function getValue2()
+ public function getValue2(): float|int|null
{
return $this->value2;
}
- /**
- * @param float|int|null $value2
- */
- public function setValue2($value2): void
+ public function setValue2(float|int|null $value2): void
{
$this->value2 = $value2;
}
- /**
- * @return string
- */
- public function getOperator(): string
+ public function getOperator(): string|null
{
return $this->operator;
}
@@ -95,18 +79,10 @@ class NumberConstraint extends AbstractConstraint
}
- public function __construct(string $property, string $identifier = null, $value1 = null, string $operator = null, $value2 = null)
- {
- parent::__construct($property, $identifier);
- $this->value1 = $value1;
- $this->value2 = $value2;
- $this->operator = $operator;
- }
-
public function isEnabled(): bool
{
return $this->value1 !== null
- && !empty($this->operator);
+ && ($this->operator !== null && $this->operator !== '');
}
public function apply(QueryBuilder $queryBuilder): void
@@ -129,7 +105,13 @@ class NumberConstraint extends AbstractConstraint
}
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier . '1', '>=', $this->value1);
- $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier . '2', '<=', $this->value2);
+
+ //Workaround for the amountSum which we need to add twice on postgres. Replace one of the __ with __2 to make it work
+ //Otherwise we get an error, that __partLot was already defined
+
+ $property2 = str_replace('__', '__2', $this->property);
+
+ $this->addSimpleAndConstraint($queryBuilder, $property2, $this->identifier . '2', '<=', $this->value2);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Filters/Constraints/Part/LessThanDesiredConstraint.php b/src/DataTables/Filters/Constraints/Part/LessThanDesiredConstraint.php
new file mode 100644
index 00000000..011824e5
--- /dev/null
+++ b/src/DataTables/Filters/Constraints/Part/LessThanDesiredConstraint.php
@@ -0,0 +1,56 @@
+.
+ */
+namespace App\DataTables\Filters\Constraints\Part;
+
+use App\DataTables\Filters\Constraints\BooleanConstraint;
+use App\Entity\Parts\PartLot;
+use Doctrine\ORM\QueryBuilder;
+
+class LessThanDesiredConstraint extends BooleanConstraint
+{
+ public function __construct(?string $property = null, ?string $identifier = null, ?bool $default_value = null)
+ {
+ parent::__construct($property ?? '(
+ SELECT COALESCE(SUM(ld_partLot.amount), 0.0)
+ FROM '.PartLot::class.' ld_partLot
+ WHERE ld_partLot.part = part.id
+ AND ld_partLot.instock_unknown = false
+ AND (ld_partLot.expiration_date IS NULL OR ld_partLot.expiration_date > CURRENT_DATE())
+ )', $identifier ?? 'amountSumLessThanDesired', $default_value);
+ }
+
+ public function apply(QueryBuilder $queryBuilder): void
+ {
+ //Do not apply a filter if value is null (filter is set to ignore)
+ if(!$this->isEnabled()) {
+ return;
+ }
+
+ //If value is true, we want to filter for parts with stock < desired stock
+ if ($this->value) {
+ $queryBuilder->andHaving( $this->property . ' < part.minamount');
+ } else {
+ $queryBuilder->andHaving($this->property . ' >= part.minamount');
+ }
+ }
+}
diff --git a/src/DataTables/Filters/Constraints/Part/ParameterConstraint.php b/src/DataTables/Filters/Constraints/Part/ParameterConstraint.php
index 76e39cdf..e68dd989 100644
--- a/src/DataTables/Filters/Constraints/Part/ParameterConstraint.php
+++ b/src/DataTables/Filters/Constraints/Part/ParameterConstraint.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters\Constraints\Part;
use App\DataTables\Filters\Constraints\AbstractConstraint;
use App\DataTables\Filters\Constraints\TextConstraint;
use App\Entity\Parameters\PartParameter;
use Doctrine\ORM\QueryBuilder;
-use Svg\Tag\Text;
class ParameterConstraint extends AbstractConstraint
{
- /** @var string */
- protected string $name;
+ protected string $name = '';
- /** @var string */
- protected string $symbol;
+ protected string $symbol = '';
- /** @var string */
- protected string $unit;
+ protected string $unit = '';
- /** @var TextConstraint */
protected TextConstraint $value_text;
-
- /** @var ParameterValueConstraint */
protected ParameterValueConstraint $value;
/** @var string The alias to use for the subquery */
@@ -73,19 +68,19 @@ class ParameterConstraint extends AbstractConstraint
->from(PartParameter::class, $this->alias)
->where($this->alias . '.element = part');
- if (!empty($this->name)) {
+ if ($this->name !== '') {
$paramName = $this->generateParameterIdentifier('params.name');
$subqb->andWhere($this->alias . '.name = :' . $paramName);
$queryBuilder->setParameter($paramName, $this->name);
}
- if (!empty($this->symbol)) {
+ if ($this->symbol !== '') {
$paramName = $this->generateParameterIdentifier('params.symbol');
$subqb->andWhere($this->alias . '.symbol = :' . $paramName);
$queryBuilder->setParameter($paramName, $this->symbol);
}
- if (!empty($this->unit)) {
+ if ($this->unit !== '') {
$paramName = $this->generateParameterIdentifier('params.unit');
$subqb->andWhere($this->alias . '.unit = :' . $paramName);
$queryBuilder->setParameter($paramName, $this->unit);
@@ -104,75 +99,48 @@ class ParameterConstraint extends AbstractConstraint
$queryBuilder->andWhere('(' . $subqb->getDQL() . ') > 0');
}
- /**
- * @return string
- */
public function getName(): string
{
return $this->name;
}
- /**
- * @param string $name
- * @return ParameterConstraint
- */
public function setName(string $name): ParameterConstraint
{
$this->name = $name;
return $this;
}
- /**
- * @return string
- */
public function getSymbol(): string
{
return $this->symbol;
}
- /**
- * @param string $symbol
- * @return ParameterConstraint
- */
public function setSymbol(string $symbol): ParameterConstraint
{
$this->symbol = $symbol;
return $this;
}
- /**
- * @return string
- */
public function getUnit(): string
{
return $this->unit;
}
- /**
- * @param string $unit
- * @return ParameterConstraint
- */
public function setUnit(string $unit): ParameterConstraint
{
$this->unit = $unit;
return $this;
}
- /**
- * @return TextConstraint
- */
public function getValueText(): TextConstraint
{
return $this->value_text;
}
- /**
- * @return ParameterValueConstraint
- */
public function getValue(): ParameterValueConstraint
{
return $this->value;
}
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Filters/Constraints/Part/ParameterValueConstraint.php b/src/DataTables/Filters/Constraints/Part/ParameterValueConstraint.php
index 5da64098..469c18c6 100644
--- a/src/DataTables/Filters/Constraints/Part/ParameterValueConstraint.php
+++ b/src/DataTables/Filters/Constraints/Part/ParameterValueConstraint.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters\Constraints\Part;
use App\DataTables\Filters\Constraints\NumberConstraint;
@@ -25,18 +27,14 @@ use Doctrine\ORM\QueryBuilder;
class ParameterValueConstraint extends NumberConstraint
{
- protected string $alias;
-
- public const ALLOWED_OPERATOR_VALUES = ['=', '!=', '<', '>', '<=', '>=', 'BETWEEN',
+ protected const ALLOWED_OPERATOR_VALUES = ['=', '!=', '<', '>', '<=', '>=', 'BETWEEN',
//Additional operators
'IN_RANGE', 'NOT_IN_RANGE', 'GREATER_THAN_RANGE', 'GREATER_EQUAL_RANGE', 'LESS_THAN_RANGE', 'LESS_EQUAL_RANGE', 'RANGE_IN_RANGE', 'RANGE_INTERSECT_RANGE'];
/**
* @param string $alias The alias which is used in the sub query of ParameterConstraint
*/
- public function __construct(string $alias) {
- $this->alias = $alias;
-
+ public function __construct(protected string $alias) {
parent::__construct($alias . '.value_typical');
}
@@ -145,4 +143,4 @@ class ParameterValueConstraint extends NumberConstraint
//For all other cases use the default implementation
parent::apply($queryBuilder);
}
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Filters/Constraints/Part/TagsConstraint.php b/src/DataTables/Filters/Constraints/Part/TagsConstraint.php
index 92c78f6a..02eab7a1 100644
--- a/src/DataTables/Filters/Constraints/Part/TagsConstraint.php
+++ b/src/DataTables/Filters/Constraints/Part/TagsConstraint.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters\Constraints\Part;
+use Doctrine\ORM\Query\Expr\Orx;
use App\DataTables\Filters\Constraints\AbstractConstraint;
-use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
class TagsConstraint extends AbstractConstraint
{
- public const ALLOWED_OPERATOR_VALUES = ['ANY', 'ALL', 'NONE'];
+ final public const ALLOWED_OPERATOR_VALUES = ['ANY', 'ALL', 'NONE'];
- /**
- * @var string|null The operator to use
- */
- protected ?string $operator;
-
- /**
- * @var string The value to compare to
- */
- protected $value;
-
- public function __construct(string $property, string $identifier = null, $value = null, string $operator = '')
+ public function __construct(string $property, ?string $identifier = null,
+ protected ?string $value = null,
+ protected ?string $operator = '')
{
parent::__construct($property, $identifier);
- $this->value = $value;
- $this->operator = $operator;
}
/**
@@ -62,18 +54,12 @@ class TagsConstraint extends AbstractConstraint
return $this;
}
- /**
- * @return string
- */
- public function getValue(): string
+ public function getValue(): ?string
{
return $this->value;
}
- /**
- * @param string $value
- */
- public function setValue(string $value): self
+ public function setValue(?string $value): self
{
$this->value = $value;
return $this;
@@ -82,7 +68,7 @@ class TagsConstraint extends AbstractConstraint
public function isEnabled(): bool
{
return $this->value !== null
- && !empty($this->operator);
+ && ($this->operator !== null && $this->operator !== '');
}
/**
@@ -96,21 +82,21 @@ class TagsConstraint extends AbstractConstraint
/**
* Builds an expression to query for a single tag
- * @param QueryBuilder $queryBuilder
- * @param string $tag
- * @return Expr\Orx
*/
- protected function getExpressionForTag(QueryBuilder $queryBuilder, string $tag): Expr\Orx
+ protected function getExpressionForTag(QueryBuilder $queryBuilder, string $tag): Orx
{
+ //Escape any %, _ or \ in the tag
+ $tag = addcslashes($tag, '%_\\');
+
$tag_identifier_prefix = uniqid($this->identifier . '_', false);
$expr = $queryBuilder->expr();
$tmp = $expr->orX(
- $expr->like($this->property, ':' . $tag_identifier_prefix . '_1'),
- $expr->like($this->property, ':' . $tag_identifier_prefix . '_2'),
- $expr->like($this->property, ':' . $tag_identifier_prefix . '_3'),
- $expr->eq($this->property, ':' . $tag_identifier_prefix . '_4'),
+ 'ILIKE(' . $this->property . ', :' . $tag_identifier_prefix . '_1) = TRUE',
+ 'ILIKE(' . $this->property . ', :' . $tag_identifier_prefix . '_2) = TRUE',
+ 'ILIKE(' . $this->property . ', :' . $tag_identifier_prefix . '_3) = TRUE',
+ 'ILIKE(' . $this->property . ', :' . $tag_identifier_prefix . '_4) = TRUE',
);
//Set the parameters for the LIKE expression, in each variation of the tag (so with a comma, at the end, at the beginning, and on both ends, and equaling the tag)
@@ -147,9 +133,10 @@ class TagsConstraint extends AbstractConstraint
return;
}
+ //@phpstan-ignore-next-line Keep this check to ensure that everything has the same structure even if we add a new operator
if ($this->operator === 'NONE') {
$queryBuilder->andWhere($queryBuilder->expr()->not($queryBuilder->expr()->orX(...$tagsExpressions)));
return;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Filters/Constraints/TextConstraint.php b/src/DataTables/Filters/Constraints/TextConstraint.php
index 3cd53973..31b12a5e 100644
--- a/src/DataTables/Filters/Constraints/TextConstraint.php
+++ b/src/DataTables/Filters/Constraints/TextConstraint.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters\Constraints;
use Doctrine\ORM\QueryBuilder;
@@ -25,23 +27,20 @@ use Doctrine\ORM\QueryBuilder;
class TextConstraint extends AbstractConstraint
{
- public const ALLOWED_OPERATOR_VALUES = ['=', '!=', 'STARTS', 'ENDS', 'CONTAINS', 'LIKE', 'REGEX'];
+ final public const ALLOWED_OPERATOR_VALUES = ['=', '!=', 'STARTS', 'ENDS', 'CONTAINS', 'LIKE', 'REGEX'];
/**
+ * @param string $value
+ */
+ public function __construct(string $property, ?string $identifier = null, /**
+ * @var string|null The value to compare to
+ */
+ protected ?string $value = null, /**
* @var string|null The operator to use
*/
- protected ?string $operator;
-
- /**
- * @var string The value to compare to
- */
- protected $value;
-
- public function __construct(string $property, string $identifier = null, $value = null, string $operator = '')
+ protected ?string $operator = '')
{
parent::__construct($property, $identifier);
- $this->value = $value;
- $this->operator = $operator;
}
/**
@@ -61,18 +60,12 @@ class TextConstraint extends AbstractConstraint
return $this;
}
- /**
- * @return string
- */
- public function getValue(): string
+ public function getValue(): ?string
{
return $this->value;
}
- /**
- * @param string $value
- */
- public function setValue(string $value): self
+ public function setValue(?string $value): self
{
$this->value = $value;
return $this;
@@ -81,7 +74,7 @@ class TextConstraint extends AbstractConstraint
public function isEnabled(): bool
{
return $this->value !== null
- && !empty($this->operator);
+ && ($this->operator !== null && $this->operator !== '');
}
public function apply(QueryBuilder $queryBuilder): void
@@ -101,27 +94,28 @@ class TextConstraint extends AbstractConstraint
return;
}
- //The CONTAINS, LIKE, STARTS and ENDS operators use the LIKE operator but we have to build the value string differently
+ //The CONTAINS, LIKE, STARTS and ENDS operators use the LIKE operator, but we have to build the value string differently
$like_value = null;
if ($this->operator === 'LIKE') {
$like_value = $this->value;
- } else if ($this->operator === 'STARTS') {
+ } elseif ($this->operator === 'STARTS') {
$like_value = $this->value . '%';
- } else if ($this->operator === 'ENDS') {
+ } elseif ($this->operator === 'ENDS') {
$like_value = '%' . $this->value;
- } else if ($this->operator === 'CONTAINS') {
+ } elseif ($this->operator === 'CONTAINS') {
$like_value = '%' . $this->value . '%';
}
if ($like_value !== null) {
- $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, 'LIKE', $like_value);
+ $queryBuilder->andWhere(sprintf('ILIKE(%s, :%s) = TRUE', $this->property, $this->identifier));
+ $queryBuilder->setParameter($this->identifier, $like_value);
return;
}
//Regex is only supported on MySQL and needs a special function
if ($this->operator === 'REGEX') {
- $queryBuilder->andWhere(sprintf('REGEXP(%s, :%s) = 1', $this->property, $this->identifier));
+ $queryBuilder->andWhere(sprintf('REGEXP(%s, :%s) = TRUE', $this->property, $this->identifier));
$queryBuilder->setParameter($this->identifier, $this->value);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Filters/FilterInterface.php b/src/DataTables/Filters/FilterInterface.php
index 1abbdc30..bead3fda 100644
--- a/src/DataTables/Filters/FilterInterface.php
+++ b/src/DataTables/Filters/FilterInterface.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters;
use Doctrine\ORM\QueryBuilder;
@@ -31,4 +33,4 @@ interface FilterInterface
* @return void
*/
public function apply(QueryBuilder $queryBuilder): void;
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Filters/LogFilter.php b/src/DataTables/Filters/LogFilter.php
index 86a600b0..35d32e74 100644
--- a/src/DataTables/Filters/LogFilter.php
+++ b/src/DataTables/Filters/LogFilter.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\ChoiceConstraint;
@@ -25,7 +27,6 @@ use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint;
use App\DataTables\Filters\Constraints\InstanceOfConstraint;
use App\DataTables\Filters\Constraints\IntConstraint;
-use App\DataTables\Filters\Constraints\NumberConstraint;
use App\Entity\UserSystem\User;
use Doctrine\ORM\QueryBuilder;
@@ -33,13 +34,13 @@ class LogFilter implements FilterInterface
{
use CompoundFilterTrait;
- protected DateTimeConstraint $timestamp;
- protected IntConstraint $dbId;
- protected ChoiceConstraint $level;
- protected InstanceOfConstraint $eventType;
- protected ChoiceConstraint $targetType;
- protected IntConstraint $targetId;
- protected EntityConstraint $user;
+ public readonly DateTimeConstraint $timestamp;
+ public readonly IntConstraint $dbId;
+ public readonly ChoiceConstraint $level;
+ public readonly InstanceOfConstraint $eventType;
+ public readonly ChoiceConstraint $targetType;
+ public readonly IntConstraint $targetId;
+ public readonly EntityConstraint $user;
public function __construct()
{
@@ -57,59 +58,4 @@ class LogFilter implements FilterInterface
{
$this->applyAllChildFilters($queryBuilder);
}
-
- /**
- * @return DateTimeConstraint
- */
- public function getTimestamp(): DateTimeConstraint
- {
- return $this->timestamp;
- }
-
- /**
- * @return IntConstraint|NumberConstraint
- */
- public function getDbId()
- {
- return $this->dbId;
- }
-
- /**
- * @return ChoiceConstraint
- */
- public function getLevel(): ChoiceConstraint
- {
- return $this->level;
- }
-
- /**
- * @return InstanceOfConstraint
- */
- public function getEventType(): InstanceOfConstraint
- {
- return $this->eventType;
- }
-
- /**
- * @return ChoiceConstraint
- */
- public function getTargetType(): ChoiceConstraint
- {
- return $this->targetType;
- }
-
- /**
- * @return IntConstraint
- */
- public function getTargetId(): IntConstraint
- {
- return $this->targetId;
- }
-
- public function getUser(): EntityConstraint
- {
- return $this->user;
- }
-
-
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php
index 2a689a8b..ff98c76f 100644
--- a/src/DataTables/Filters/PartFilter.php
+++ b/src/DataTables/Filters/PartFilter.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\BooleanConstraint;
@@ -26,6 +28,7 @@ use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint;
use App\DataTables\Filters\Constraints\IntConstraint;
use App\DataTables\Filters\Constraints\NumberConstraint;
+use App\DataTables\Filters\Constraints\Part\LessThanDesiredConstraint;
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
use App\DataTables\Filters\Constraints\Part\TagsConstraint;
use App\DataTables\Filters\Constraints\TextConstraint;
@@ -34,53 +37,69 @@ use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
-use App\Entity\Parts\Storelocation;
+use App\Entity\Parts\PartLot;
+use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
+use App\Entity\ProjectSystem\Project;
+use App\Entity\UserSystem\User;
use App\Services\Trees\NodesListBuilder;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\QueryBuilder;
-use Svg\Tag\Text;
class PartFilter implements FilterInterface
{
use CompoundFilterTrait;
- protected IntConstraint $dbId;
- protected TextConstraint $ipn;
- protected TextConstraint $name;
- protected TextConstraint $description;
- protected TextConstraint $comment;
- protected TagsConstraint $tags;
- protected NumberConstraint $minAmount;
- protected BooleanConstraint $favorite;
- protected BooleanConstraint $needsReview;
- protected NumberConstraint $mass;
- protected DateTimeConstraint $lastModified;
- protected DateTimeConstraint $addedDate;
- protected EntityConstraint $category;
- protected EntityConstraint $footprint;
- protected EntityConstraint $manufacturer;
- protected ChoiceConstraint $manufacturing_status;
- protected EntityConstraint $supplier;
- protected IntConstraint $orderdetailsCount;
- protected BooleanConstraint $obsolete;
- protected EntityConstraint $storelocation;
- protected IntConstraint $lotCount;
- protected IntConstraint $amountSum;
- protected BooleanConstraint $lotNeedsRefill;
- protected TextConstraint $lotDescription;
- protected BooleanConstraint $lotUnknownAmount;
- protected DateTimeConstraint $lotExpirationDate;
- protected EntityConstraint $measurementUnit;
- protected TextConstraint $manufacturer_product_url;
- protected TextConstraint $manufacturer_product_number;
- protected IntConstraint $attachmentsCount;
- protected EntityConstraint $attachmentType;
- protected TextConstraint $attachmentName;
+ public readonly IntConstraint $dbId;
+ public readonly TextConstraint $ipn;
+ public readonly TextConstraint $name;
+ public readonly TextConstraint $description;
+ public readonly TextConstraint $comment;
+ public readonly TagsConstraint $tags;
+ public readonly NumberConstraint $minAmount;
+ public readonly BooleanConstraint $favorite;
+ public readonly BooleanConstraint $needsReview;
+ public readonly NumberConstraint $mass;
+ public readonly DateTimeConstraint $lastModified;
+ public readonly DateTimeConstraint $addedDate;
+ public readonly EntityConstraint $category;
+ public readonly EntityConstraint $footprint;
+ public readonly EntityConstraint $manufacturer;
+ public readonly ChoiceConstraint $manufacturing_status;
+ public readonly EntityConstraint $supplier;
+ public readonly IntConstraint $orderdetailsCount;
+ public readonly BooleanConstraint $obsolete;
+ public readonly EntityConstraint $storelocation;
+ public readonly IntConstraint $lotCount;
+ public readonly IntConstraint $amountSum;
+ public readonly LessThanDesiredConstraint $lessThanDesired;
+
+ public readonly BooleanConstraint $lotNeedsRefill;
+ public readonly TextConstraint $lotDescription;
+ public readonly BooleanConstraint $lotUnknownAmount;
+ public readonly DateTimeConstraint $lotExpirationDate;
+ public readonly EntityConstraint $lotOwner;
+
+ public readonly EntityConstraint $measurementUnit;
+ public readonly TextConstraint $manufacturer_product_url;
+ public readonly TextConstraint $manufacturer_product_number;
+ public readonly IntConstraint $attachmentsCount;
+ public readonly EntityConstraint $attachmentType;
+ public readonly TextConstraint $attachmentName;
+
/** @var ArrayCollection */
- protected ArrayCollection $parameters;
- protected IntConstraint $parametersCount;
+ public readonly ArrayCollection $parameters;
+ public readonly IntConstraint $parametersCount;
+
+ /*************************************************
+ * Project tab
+ *************************************************/
+
+ public readonly EntityConstraint $project;
+ public readonly NumberConstraint $bomQuantity;
+ public readonly TextConstraint $bomName;
+ public readonly TextConstraint $bomComment;
public function __construct(NodesListBuilder $nodesListBuilder)
{
@@ -105,284 +124,48 @@ class PartFilter implements FilterInterface
This seems to be related to the fact, that PDO does not have an float parameter type and using string type does not work in this situation (at least in SQLite)
TODO: Find a better solution here
*/
- $this->amountSum = new IntConstraint('amountSum');
- $this->lotCount = new IntConstraint('COUNT(partLots)');
+ $this->amountSum = (new IntConstraint('(
+ SELECT COALESCE(SUM(__partLot.amount), 0.0)
+ FROM '.PartLot::class.' __partLot
+ WHERE __partLot.part = part.id
+ AND __partLot.instock_unknown = false
+ AND (__partLot.expiration_date IS NULL OR __partLot.expiration_date > CURRENT_DATE())
+ )', identifier: "amountSumWhere"));
+ $this->lotCount = new IntConstraint('COUNT(_partLots)');
+ $this->lessThanDesired = new LessThanDesiredConstraint();
- $this->storelocation = new EntityConstraint($nodesListBuilder, Storelocation::class, 'partLots.storage_location');
- $this->lotNeedsRefill = new BooleanConstraint('partLots.needs_refill');
- $this->lotUnknownAmount = new BooleanConstraint('partLots.instock_unknown');
- $this->lotExpirationDate = new DateTimeConstraint('partLots.expiration_date');
- $this->lotDescription = new TextConstraint('partLots.description');
+ $this->storelocation = new EntityConstraint($nodesListBuilder, StorageLocation::class, '_partLots.storage_location');
+ $this->lotNeedsRefill = new BooleanConstraint('_partLots.needs_refill');
+ $this->lotUnknownAmount = new BooleanConstraint('_partLots.instock_unknown');
+ $this->lotExpirationDate = new DateTimeConstraint('_partLots.expiration_date');
+ $this->lotDescription = new TextConstraint('_partLots.description');
+ $this->lotOwner = new EntityConstraint($nodesListBuilder, User::class, '_partLots.owner');
$this->manufacturer = new EntityConstraint($nodesListBuilder, Manufacturer::class, 'part.manufacturer');
$this->manufacturer_product_number = new TextConstraint('part.manufacturer_product_number');
$this->manufacturer_product_url = new TextConstraint('part.manufacturer_product_url');
$this->manufacturing_status = new ChoiceConstraint('part.manufacturing_status');
- $this->attachmentsCount = new IntConstraint('COUNT(attachments)');
- $this->attachmentType = new EntityConstraint($nodesListBuilder, AttachmentType::class, 'attachments.attachment_type');
- $this->attachmentName = new TextConstraint('attachments.name');
+ $this->attachmentsCount = new IntConstraint('COUNT(_attachments)');
+ $this->attachmentType = new EntityConstraint($nodesListBuilder, AttachmentType::class, '_attachments.attachment_type');
+ $this->attachmentName = new TextConstraint('_attachments.name');
- $this->supplier = new EntityConstraint($nodesListBuilder, Supplier::class, 'orderdetails.supplier');
- $this->orderdetailsCount = new IntConstraint('COUNT(orderdetails)');
- $this->obsolete = new BooleanConstraint('orderdetails.obsolete');
+ $this->supplier = new EntityConstraint($nodesListBuilder, Supplier::class, '_orderdetails.supplier');
+ $this->orderdetailsCount = new IntConstraint('COUNT(_orderdetails)');
+ $this->obsolete = new BooleanConstraint('_orderdetails.obsolete');
$this->parameters = new ArrayCollection();
- $this->parametersCount = new IntConstraint('COUNT(parameters)');
+ $this->parametersCount = new IntConstraint('COUNT(_parameters)');
+
+ $this->project = new EntityConstraint($nodesListBuilder, Project::class, '_projectBomEntries.project');
+ $this->bomQuantity = new NumberConstraint('_projectBomEntries.quantity');
+ $this->bomName = new TextConstraint('_projectBomEntries.name');
+ $this->bomComment = new TextConstraint('_projectBomEntries.comment');
+
}
public function apply(QueryBuilder $queryBuilder): void
{
$this->applyAllChildFilters($queryBuilder);
}
-
-
- /**
- * @return BooleanConstraint|false
- */
- public function getFavorite()
- {
- return $this->favorite;
- }
-
- /**
- * @return BooleanConstraint
- */
- public function getNeedsReview(): BooleanConstraint
- {
- return $this->needsReview;
- }
-
- public function getMass(): NumberConstraint
- {
- return $this->mass;
- }
-
- public function getName(): TextConstraint
- {
- return $this->name;
- }
-
- public function getDescription(): TextConstraint
- {
- return $this->description;
- }
-
- /**
- * @return DateTimeConstraint
- */
- public function getLastModified(): DateTimeConstraint
- {
- return $this->lastModified;
- }
-
- /**
- * @return DateTimeConstraint
- */
- public function getAddedDate(): DateTimeConstraint
- {
- return $this->addedDate;
- }
-
- public function getCategory(): EntityConstraint
- {
- return $this->category;
- }
-
- /**
- * @return EntityConstraint
- */
- public function getFootprint(): EntityConstraint
- {
- return $this->footprint;
- }
-
- /**
- * @return EntityConstraint
- */
- public function getManufacturer(): EntityConstraint
- {
- return $this->manufacturer;
- }
-
- /**
- * @return EntityConstraint
- */
- public function getSupplier(): EntityConstraint
- {
- return $this->supplier;
- }
-
- /**
- * @return EntityConstraint
- */
- public function getStorelocation(): EntityConstraint
- {
- return $this->storelocation;
- }
-
- /**
- * @return EntityConstraint
- */
- public function getMeasurementUnit(): EntityConstraint
- {
- return $this->measurementUnit;
- }
-
- /**
- * @return NumberConstraint
- */
- public function getDbId(): NumberConstraint
- {
- return $this->dbId;
- }
-
- public function getIpn(): TextConstraint
- {
- return $this->ipn;
- }
-
- /**
- * @return TextConstraint
- */
- public function getComment(): TextConstraint
- {
- return $this->comment;
- }
-
- /**
- * @return NumberConstraint
- */
- public function getMinAmount(): NumberConstraint
- {
- return $this->minAmount;
- }
-
- /**
- * @return TextConstraint
- */
- public function getManufacturerProductUrl(): TextConstraint
- {
- return $this->manufacturer_product_url;
- }
-
- /**
- * @return TextConstraint
- */
- public function getManufacturerProductNumber(): TextConstraint
- {
- return $this->manufacturer_product_number;
- }
-
- public function getLotCount(): NumberConstraint
- {
- return $this->lotCount;
- }
-
- /**
- * @return TagsConstraint
- */
- public function getTags(): TagsConstraint
- {
- return $this->tags;
- }
-
- /**
- * @return IntConstraint
- */
- public function getOrderdetailsCount(): IntConstraint
- {
- return $this->orderdetailsCount;
- }
-
- /**
- * @return IntConstraint
- */
- public function getAttachmentsCount(): IntConstraint
- {
- return $this->attachmentsCount;
- }
-
- /**
- * @return BooleanConstraint
- */
- public function getLotNeedsRefill(): BooleanConstraint
- {
- return $this->lotNeedsRefill;
- }
-
- /**
- * @return BooleanConstraint
- */
- public function getLotUnknownAmount(): BooleanConstraint
- {
- return $this->lotUnknownAmount;
- }
-
- /**
- * @return DateTimeConstraint
- */
- public function getLotExpirationDate(): DateTimeConstraint
- {
- return $this->lotExpirationDate;
- }
-
- /**
- * @return EntityConstraint
- */
- public function getAttachmentType(): EntityConstraint
- {
- return $this->attachmentType;
- }
-
- /**
- * @return TextConstraint
- */
- public function getAttachmentName(): TextConstraint
- {
- return $this->attachmentName;
- }
-
- public function getManufacturingStatus(): ChoiceConstraint
- {
- return $this->manufacturing_status;
- }
-
- public function getAmountSum(): NumberConstraint
- {
- return $this->amountSum;
- }
-
- /**
- * @return ArrayCollection
- */
- public function getParameters(): ArrayCollection
- {
- return $this->parameters;
- }
-
- public function getParametersCount(): IntConstraint
- {
- return $this->parametersCount;
- }
-
- /**
- * @return TextConstraint
- */
- public function getLotDescription(): TextConstraint
- {
- return $this->lotDescription;
- }
-
- /**
- * @return BooleanConstraint
- */
- public function getObsolete(): BooleanConstraint
- {
- return $this->obsolete;
- }
-
-
-
-
}
diff --git a/src/DataTables/Filters/PartSearchFilter.php b/src/DataTables/Filters/PartSearchFilter.php
index 6dfe2a62..6e2e5894 100644
--- a/src/DataTables/Filters/PartSearchFilter.php
+++ b/src/DataTables/Filters/PartSearchFilter.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables\Filters;
-
-use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
class PartSearchFilter implements FilterInterface
{
- /** @var string The string to query for */
- protected string $keyword;
-
/** @var boolean Whether to use regex for searching */
protected bool $regex = false;
@@ -65,9 +62,14 @@ class PartSearchFilter implements FilterInterface
/** @var bool Use footprint name for searching */
protected bool $footprint = false;
- public function __construct(string $query)
+ /** @var bool Use Internal Part number for searching */
+ protected bool $ipn = true;
+
+ public function __construct(
+ /** @var string The string to query for */
+ protected string $keyword
+ )
{
- $this->keyword = $query;
}
protected function getFieldsToSearch(): array
@@ -78,31 +80,37 @@ class PartSearchFilter implements FilterInterface
$fields_to_search[] = 'part.name';
}
if($this->category) {
- $fields_to_search[] = 'category.name';
+ $fields_to_search[] = '_category.name';
}
if($this->description) {
$fields_to_search[] = 'part.description';
}
+ if ($this->comment) {
+ $fields_to_search[] = 'part.comment';
+ }
if($this->tags) {
$fields_to_search[] = 'part.tags';
}
if($this->storelocation) {
- $fields_to_search[] = 'storelocations.name';
+ $fields_to_search[] = '_storelocations.name';
}
if($this->ordernr) {
- $fields_to_search[] = 'orderdetails.supplierpartnr';
+ $fields_to_search[] = '_orderdetails.supplierpartnr';
}
if($this->mpn) {
- $fields_to_search[] = 'part.manufacturer_product_url';
+ $fields_to_search[] = 'part.manufacturer_product_number';
}
if($this->supplier) {
- $fields_to_search[] = 'suppliers.name';
+ $fields_to_search[] = '_suppliers.name';
}
if($this->manufacturer) {
- $fields_to_search[] = 'manufacturer.name';
+ $fields_to_search[] = '_manufacturer.name';
}
if($this->footprint) {
- $fields_to_search[] = 'footprint.name';
+ $fields_to_search[] = '_footprint.name';
+ }
+ if ($this->ipn) {
+ $fields_to_search[] = 'part.ipn';
}
return $fields_to_search;
@@ -113,25 +121,25 @@ class PartSearchFilter implements FilterInterface
$fields_to_search = $this->getFieldsToSearch();
//If we have nothing to search for, do nothing
- if (empty($fields_to_search) || empty($this->keyword)) {
+ if ($fields_to_search === [] || $this->keyword === '') {
return;
}
//Convert the fields to search to a list of expressions
- $expressions = array_map(function (string $field) {
+ $expressions = array_map(function (string $field): string {
if ($this->regex) {
- return sprintf("REGEXP(%s, :search_query) = 1", $field);
+ return sprintf("REGEXP(%s, :search_query) = TRUE", $field);
}
- return sprintf("%s LIKE :search_query", $field);
+ return sprintf("ILIKE(%s, :search_query) = TRUE", $field);
}, $fields_to_search);
- //Add Or concatation of the expressions to our query
+ //Add Or concatenation of the expressions to our query
$queryBuilder->andWhere(
$queryBuilder->expr()->orX(...$expressions)
);
- //For regex we pass the query as is, for like we add % to the start and end as wildcards
+ //For regex, we pass the query as is, for like we add % to the start and end as wildcards
if ($this->regex) {
$queryBuilder->setParameter('search_query', $this->keyword);
} else {
@@ -139,234 +147,154 @@ class PartSearchFilter implements FilterInterface
}
}
- /**
- * @return string
- */
public function getKeyword(): string
{
return $this->keyword;
}
- /**
- * @param string $keyword
- * @return PartSearchFilter
- */
public function setKeyword(string $keyword): PartSearchFilter
{
$this->keyword = $keyword;
return $this;
}
- /**
- * @return bool
- */
public function isRegex(): bool
{
return $this->regex;
}
- /**
- * @param bool $regex
- * @return PartSearchFilter
- */
public function setRegex(bool $regex): PartSearchFilter
{
$this->regex = $regex;
return $this;
}
- /**
- * @return bool
- */
public function isName(): bool
{
return $this->name;
}
- /**
- * @param bool $name
- * @return PartSearchFilter
- */
public function setName(bool $name): PartSearchFilter
{
$this->name = $name;
return $this;
}
- /**
- * @return bool
- */
public function isCategory(): bool
{
return $this->category;
}
- /**
- * @param bool $category
- * @return PartSearchFilter
- */
public function setCategory(bool $category): PartSearchFilter
{
$this->category = $category;
return $this;
}
- /**
- * @return bool
- */
public function isDescription(): bool
{
return $this->description;
}
- /**
- * @param bool $description
- * @return PartSearchFilter
- */
public function setDescription(bool $description): PartSearchFilter
{
$this->description = $description;
return $this;
}
- /**
- * @return bool
- */
public function isTags(): bool
{
return $this->tags;
}
- /**
- * @param bool $tags
- * @return PartSearchFilter
- */
public function setTags(bool $tags): PartSearchFilter
{
$this->tags = $tags;
return $this;
}
- /**
- * @return bool
- */
public function isStorelocation(): bool
{
return $this->storelocation;
}
- /**
- * @param bool $storelocation
- * @return PartSearchFilter
- */
public function setStorelocation(bool $storelocation): PartSearchFilter
{
$this->storelocation = $storelocation;
return $this;
}
- /**
- * @return bool
- */
public function isOrdernr(): bool
{
return $this->ordernr;
}
- /**
- * @param bool $ordernr
- * @return PartSearchFilter
- */
public function setOrdernr(bool $ordernr): PartSearchFilter
{
$this->ordernr = $ordernr;
return $this;
}
- /**
- * @return bool
- */
public function isMpn(): bool
{
return $this->mpn;
}
- /**
- * @param bool $mpn
- * @return PartSearchFilter
- */
public function setMpn(bool $mpn): PartSearchFilter
{
$this->mpn = $mpn;
return $this;
}
- /**
- * @return bool
- */
+ public function isIPN(): bool
+ {
+ return $this->ipn;
+ }
+
+ public function setIPN(bool $ipn): PartSearchFilter
+ {
+ $this->ipn = $ipn;
+ return $this;
+ }
+
public function isSupplier(): bool
{
return $this->supplier;
}
- /**
- * @param bool $supplier
- * @return PartSearchFilter
- */
public function setSupplier(bool $supplier): PartSearchFilter
{
$this->supplier = $supplier;
return $this;
}
- /**
- * @return bool
- */
public function isManufacturer(): bool
{
return $this->manufacturer;
}
- /**
- * @param bool $manufacturer
- * @return PartSearchFilter
- */
public function setManufacturer(bool $manufacturer): PartSearchFilter
{
$this->manufacturer = $manufacturer;
return $this;
}
- /**
- * @return bool
- */
public function isFootprint(): bool
{
return $this->footprint;
}
- /**
- * @param bool $footprint
- * @return PartSearchFilter
- */
public function setFootprint(bool $footprint): PartSearchFilter
{
$this->footprint = $footprint;
return $this;
}
- /**
- * @return bool
- */
public function isComment(): bool
{
return $this->comment;
}
- /**
- * @param bool $comment
- * @return PartSearchFilter
- */
public function setComment(bool $comment): PartSearchFilter
{
$this->comment = $comment;
@@ -374,4 +302,4 @@ class PartSearchFilter implements FilterInterface
}
-}
\ No newline at end of file
+}
diff --git a/src/DataTables/Helpers/ColumnSortHelper.php b/src/DataTables/Helpers/ColumnSortHelper.php
new file mode 100644
index 00000000..05bd8182
--- /dev/null
+++ b/src/DataTables/Helpers/ColumnSortHelper.php
@@ -0,0 +1,130 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\DataTables\Helpers;
+
+use Omines\DataTablesBundle\DataTable;
+use Psr\Log\LoggerInterface;
+
+class ColumnSortHelper
+{
+ private array $columns = [];
+
+ public function __construct(private readonly LoggerInterface $logger)
+ {
+ }
+
+ /**
+ * Add a new column which can be sorted and visibility controlled by the user. The basic syntax is similar to
+ * the DataTable add method, but with additional options.
+ * @param string $name
+ * @param string $type
+ * @param array $options
+ * @param string|null $alias If an alias is set here, the column will be available under this alias in the config
+ * string instead of the name.
+ * @param bool $visibility_configurable If set to false, this column can not be visibility controlled by the user
+ * @return $this
+ */
+ public function add(string $name, string $type, array $options = [], ?string $alias = null,
+ bool $visibility_configurable = true): self
+ {
+ //Alias allows us to override the name of the column in the env variable
+ $this->columns[$alias ?? $name] = [
+ 'name' => $name,
+ 'type' => $type,
+ 'options' => $options,
+ 'visibility_configurable' => $visibility_configurable
+ ];
+
+ return $this;
+ }
+
+ /**
+ * Remove all columns saved inside this helper
+ * @return void
+ */
+ public function reset(): void
+ {
+ $this->columns = [];
+ }
+
+ /**
+ * Apply the visibility configuration to the given DataTable and configure the columns.
+ * @param DataTable $dataTable
+ * @param string|array $visible_columns Either a list or a comma separated string of column names, which should
+ * be visible by default. If a column is not listed here, it will be hidden by default.
+ * @return void
+ */
+ public function applyVisibilityAndConfigureColumns(DataTable $dataTable, string|array $visible_columns,
+ string $config_var_name): void
+ {
+ //If the config is given as a string, convert it to an array first
+ if (!is_array($visible_columns)) {
+ $visible_columns = array_map(trim(...), explode(",", $visible_columns));
+ }
+
+ $processed_columns = [];
+
+ //First add all columns which visibility is not configurable
+ foreach ($this->columns as $col_id => $col_data) {
+ if (!$col_data['visibility_configurable']) {
+ $this->addColumnEntry($dataTable, $this->columns[$col_id], null);
+ $processed_columns[] = $col_id;
+ }
+ }
+
+ //Afterwards the columns, which should be visible by default
+ foreach ($visible_columns as $col_id) {
+ if (!isset($this->columns[$col_id]) || !$this->columns[$col_id]['visibility_configurable']) {
+ $this->logger->warning("Configuration option $config_var_name specify invalid column '$col_id'. Column is skipped.");
+ continue;
+ }
+
+ if (in_array($col_id, $processed_columns, true)) {
+ $this->logger->warning("Configuration option $config_var_name specify column '$col_id' multiple time. Only first occurrence is used.");
+ continue;
+ }
+ $this->addColumnEntry($dataTable, $this->columns[$col_id], true);
+ $processed_columns[] = $col_id;
+ }
+
+ //and the remaining non-visible columns
+ foreach (array_keys($this->columns) as $col_id) {
+ if (in_array($col_id, $processed_columns, true)) {
+ // column already processed
+ continue;
+ }
+ $this->addColumnEntry($dataTable, $this->columns[$col_id], false);
+ $processed_columns[] = $col_id;
+ }
+ }
+
+ private function addColumnEntry(DataTable $dataTable, array $entry, ?bool $visible): void
+ {
+ $options = $entry['options'] ?? [];
+ if (!is_null($visible)) {
+ $options["visible"] = $visible;
+ }
+ $dataTable->add($entry['name'], $entry['type'], $options);
+ }
+}
\ No newline at end of file
diff --git a/src/DataTables/Helpers/PartDataTableHelper.php b/src/DataTables/Helpers/PartDataTableHelper.php
index 90255835..c33c3a82 100644
--- a/src/DataTables/Helpers/PartDataTableHelper.php
+++ b/src/DataTables/Helpers/PartDataTableHelper.php
@@ -1,4 +1,7 @@
previewGenerator = $previewGenerator;
- $this->attachmentURLGenerator = $attachmentURLGenerator;
- $this->translator = $translator;
- $this->entityURLGenerator = $entityURLGenerator;
+ public function __construct(
+ private readonly PartPreviewGenerator $previewGenerator,
+ private readonly AttachmentURLGenerator $attachmentURLGenerator,
+ private readonly EntityURLGenerator $entityURLGenerator,
+ private readonly TranslatorInterface $translator,
+ private readonly AmountFormatter $amountFormatter,
+ ) {
}
public function renderName(Part $context): string
@@ -52,14 +53,16 @@ class PartDataTableHelper
//Depending on the part status we show a different icon (the later conditions have higher priority)
if ($context->isFavorite()) {
- $icon = sprintf('', $this->translator->trans('part.favorite.badge'));
+ $icon = sprintf('',
+ $this->translator->trans('part.favorite.badge'));
}
if ($context->isNeedsReview()) {
- $icon = sprintf('', $this->translator->trans('part.needs_review.badge'));
+ $icon = sprintf('',
+ $this->translator->trans('part.needs_review.badge'));
}
- if ($context->getBuiltProject() !== null) {
+ if ($context->getBuiltProject() instanceof Project) {
$icon = sprintf('',
- $this->translator->trans('part.info.projectBuildPart.hint') . ': ' . $context->getBuiltProject()->getName());
+ $this->translator->trans('part.info.projectBuildPart.hint').': '.$context->getBuiltProject()->getName());
}
@@ -67,14 +70,14 @@ class PartDataTableHelper
'%s%s',
$this->entityURLGenerator->infoURL($context),
$icon,
- htmlentities($context->getName())
+ htmlspecialchars($context->getName())
);
}
public function renderPicture(Part $context): string
{
$preview_attachment = $this->previewGenerator->getTablePreviewAttachment($context);
- if (null === $preview_attachment) {
+ if (!$preview_attachment instanceof Attachment) {
return '';
}
@@ -88,8 +91,66 @@ class PartDataTableHelper
'Part image',
$this->attachmentURLGenerator->getThumbnailURL($preview_attachment),
$this->attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_md'),
- 'img-fluid hoverpic',
+ 'hoverpic part-table-image',
$title
);
}
-}
\ No newline at end of file
+
+ public function renderStorageLocations(Part $context): string
+ {
+ $tmp = [];
+ foreach ($context->getPartLots() as $lot) {
+ //Ignore lots without storelocation
+ if (!$lot->getStorageLocation() instanceof StorageLocation) {
+ continue;
+ }
+ $tmp[] = sprintf(
+ '%s',
+ $this->entityURLGenerator->listPartsURL($lot->getStorageLocation()),
+ htmlspecialchars($lot->getStorageLocation()->getFullPath()),
+ htmlspecialchars($lot->getStorageLocation()->getName())
+ );
+ }
+
+ return implode(' ', $tmp);
+ }
+
+ public function renderAmount(Part $context): string
+ {
+ $amount = $context->getAmountSum();
+ $expiredAmount = $context->getExpiredAmountSum();
+
+ $ret = '';
+
+ if ($context->isAmountUnknown()) {
+ //When all amounts are unknown, we show a question mark
+ if ($amount === 0.0) {
+ $ret .= sprintf('?',
+ $this->translator->trans('part_lots.instock_unknown'));
+ } else { //Otherwise mark it with greater equal and the (known) amount
+ $ret .= sprintf('≥',
+ $this->translator->trans('part_lots.instock_unknown')
+ );
+ $ret .= htmlspecialchars($this->amountFormatter->format($amount, $context->getPartUnit()));
+ }
+ } else {
+ $ret .= htmlspecialchars($this->amountFormatter->format($amount, $context->getPartUnit()));
+ }
+
+ //If we have expired lots, we show them in parentheses behind
+ if ($expiredAmount > 0) {
+ $ret .= sprintf(' (+%s)',
+ $this->translator->trans('part_lots.is_expired'),
+ htmlspecialchars($this->amountFormatter->format($expiredAmount, $context->getPartUnit())));
+ }
+
+ //When the amount is below the minimum amount, we highlight the number red
+ if ($context->isNotEnoughInstock()) {
+ $ret = sprintf('%s',
+ $this->translator->trans('part.info.amount.less_than_desired'),
+ $ret);
+ }
+
+ return $ret;
+ }
+}
diff --git a/src/DataTables/LogDataTable.php b/src/DataTables/LogDataTable.php
index 5d23446a..f6604279 100644
--- a/src/DataTables/LogDataTable.php
+++ b/src/DataTables/LogDataTable.php
@@ -22,13 +22,15 @@ declare(strict_types=1);
namespace App\DataTables;
+use App\DataTables\Column\EnumColumn;
+use App\Entity\LogSystem\LogTargetType;
+use Symfony\Bundle\SecurityBundle\Security;
use App\DataTables\Column\IconLinkColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\LogEntryExtraColumn;
use App\DataTables\Column\LogEntryTargetColumn;
use App\DataTables\Column\RevertLogColumn;
use App\DataTables\Column\RowClassColumn;
-use App\DataTables\Filters\AttachmentFilter;
use App\DataTables\Filters\LogFilter;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Contracts\TimeTravelInterface;
@@ -38,12 +40,12 @@ use App\Entity\LogSystem\ElementCreatedLogEntry;
use App\Entity\LogSystem\ElementDeletedLogEntry;
use App\Entity\LogSystem\ElementEditedLogEntry;
use App\Entity\LogSystem\PartStockChangedLogEntry;
-use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use App\Exceptions\EntityNotSupportedException;
use App\Repository\LogEntryRepository;
use App\Services\ElementTypeNameGenerator;
use App\Services\EntityURLGenerator;
+use App\Services\LogSystem\LogLevelHelper;
use App\Services\UserSystem\UserAvatarHelper;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
@@ -52,36 +54,20 @@ use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter;
use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface;
-use Psr\Log\LogLevel;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
-use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
-use function Symfony\Component\Translation\t;
-
class LogDataTable implements DataTableTypeInterface
{
- protected ElementTypeNameGenerator $elementTypeNameGenerator;
- protected TranslatorInterface $translator;
- protected UrlGeneratorInterface $urlGenerator;
- protected EntityURLGenerator $entityURLGenerator;
protected LogEntryRepository $logRepo;
- protected Security $security;
- protected UserAvatarHelper $userAvatarHelper;
- public function __construct(ElementTypeNameGenerator $elementTypeNameGenerator, TranslatorInterface $translator,
- UrlGeneratorInterface $urlGenerator, EntityURLGenerator $entityURLGenerator, EntityManagerInterface $entityManager,
- Security $security, UserAvatarHelper $userAvatarHelper)
+ public function __construct(protected ElementTypeNameGenerator $elementTypeNameGenerator, protected TranslatorInterface $translator,
+ protected UrlGeneratorInterface $urlGenerator, protected EntityURLGenerator $entityURLGenerator, EntityManagerInterface $entityManager,
+ protected Security $security, protected UserAvatarHelper $userAvatarHelper, protected LogLevelHelper $logLevelHelper)
{
- $this->elementTypeNameGenerator = $elementTypeNameGenerator;
- $this->translator = $translator;
- $this->urlGenerator = $urlGenerator;
- $this->entityURLGenerator = $entityURLGenerator;
$this->logRepo = $entityManager->getRepository(AbstractLogEntry::class);
- $this->security = $security;
- $this->userAvatarHelper = $userAvatarHelper;
}
public function configureOptions(OptionsResolver $optionsResolver): void
@@ -115,72 +101,17 @@ class LogDataTable implements DataTableTypeInterface
//This special $$rowClass column is used to set the row class depending on the log level. The class gets set by the frontend controller
$dataTable->add('dont_matter', RowClassColumn::class, [
- 'render' => static function ($value, AbstractLogEntry $context) {
- switch ($context->getLevel()) {
- case AbstractLogEntry::LEVEL_EMERGENCY:
- case AbstractLogEntry::LEVEL_ALERT:
- case AbstractLogEntry::LEVEL_CRITICAL:
- case AbstractLogEntry::LEVEL_ERROR:
- return 'table-danger';
- case AbstractLogEntry::LEVEL_WARNING:
- return 'table-warning';
- case AbstractLogEntry::LEVEL_NOTICE:
- return 'table-info';
- default:
- return '';
- }
- },
+ 'render' => fn($value, AbstractLogEntry $context) => $this->logLevelHelper->logLevelToTableColorClass($context->getLevelString()),
]);
$dataTable->add('symbol', TextColumn::class, [
'label' => '',
'className' => 'no-colvis',
- 'render' => static function ($value, AbstractLogEntry $context) {
- switch ($context->getLevelString()) {
- case LogLevel::DEBUG:
- $symbol = 'fa-bug';
-
- break;
- case LogLevel::INFO:
- $symbol = 'fa-info';
-
- break;
- case LogLevel::NOTICE:
- $symbol = 'fa-flag';
-
- break;
- case LogLevel::WARNING:
- $symbol = 'fa-exclamation-circle';
-
- break;
- case LogLevel::ERROR:
- $symbol = 'fa-exclamation-triangle';
-
- break;
- case LogLevel::CRITICAL:
- $symbol = 'fa-bolt';
-
- break;
- case LogLevel::ALERT:
- $symbol = 'fa-radiation';
-
- break;
- case LogLevel::EMERGENCY:
- $symbol = 'fa-skull-crossbones';
-
- break;
- default:
- $symbol = 'fa-question-circle';
-
- break;
- }
-
- return sprintf(
- '',
- $symbol,
- $context->getLevelString()
- );
- },
+ 'render' => fn($value, AbstractLogEntry $context): string => sprintf(
+ '',
+ $this->logLevelHelper->logLevelToIconClass($context->getLevelString()),
+ $context->getLevelString()
+ ),
]);
$dataTable->add('id', TextColumn::class, [
@@ -191,6 +122,10 @@ class LogDataTable implements DataTableTypeInterface
$dataTable->add('timestamp', LocaleDateTimeColumn::class, [
'label' => 'log.timestamp',
'timeFormat' => 'medium',
+ 'render' => fn(string $value, AbstractLogEntry $context): string => sprintf('%s',
+ $this->urlGenerator->generate('log_details', ['id' => $context->getID()]),
+ $value
+ )
]);
$dataTable->add('type', TextColumn::class, [
@@ -202,7 +137,7 @@ class LogDataTable implements DataTableTypeInterface
if ($context instanceof PartStockChangedLogEntry) {
$text .= sprintf(
' (%s)',
- $this->translator->trans('log.part_stock_changed.' . $context->getInstockChangeType())
+ $this->translator->trans($context->getInstockChangeType()->toTranslationKey())
);
}
@@ -214,18 +149,25 @@ class LogDataTable implements DataTableTypeInterface
'label' => 'log.level',
'visible' => 'system_log' === $options['mode'],
'propertyPath' => 'levelString',
- 'render' => function (string $value, AbstractLogEntry $context) {
- return $this->translator->trans('log.level.'.$value);
- },
+ 'render' => fn(string $value, AbstractLogEntry $context) => $this->translator->trans('log.level.'.$value),
]);
$dataTable->add('user', TextColumn::class, [
'label' => 'log.user',
- 'render' => function ($value, AbstractLogEntry $context) {
+ 'orderField' => 'NATSORT(user.name)',
+ 'render' => function ($value, AbstractLogEntry $context): string {
$user = $context->getUser();
//If user was deleted, show the info from the username field
- if ($user === null) {
+ if (!$user instanceof User) {
+ if ($context->isCLIEntry()) {
+ return sprintf('%s [%s]',
+ htmlentities((string) $context->getCLIUsername()),
+ $this->translator->trans('log.cli_user')
+ );
+ }
+
+ //Else we just deal with a deleted user
return sprintf(
'@%s [%s]',
htmlentities($context->getUsername()),
@@ -245,11 +187,12 @@ class LogDataTable implements DataTableTypeInterface
},
]);
- $dataTable->add('target_type', TextColumn::class, [
+ $dataTable->add('target_type', EnumColumn::class, [
'label' => 'log.target_type',
'visible' => false,
- 'render' => function ($value, AbstractLogEntry $context) {
- $class = $context->getTargetClass();
+ 'class' => LogTargetType::class,
+ 'render' => function (LogTargetType $value, AbstractLogEntry $context) {
+ $class = $value->toClass();
if (null !== $class) {
return $this->elementTypeNameGenerator->getLocalizedTypeLabel($class);
}
@@ -273,24 +216,22 @@ class LogDataTable implements DataTableTypeInterface
'href' => function ($value, AbstractLogEntry $context) {
if (
($context instanceof TimeTravelInterface
- && $context->hasOldDataInformations())
+ && $context->hasOldDataInformation())
|| $context instanceof CollectionElementDeleted
) {
try {
$target = $this->logRepo->getTargetElement($context);
- if (null !== $target) {
+ if ($target instanceof AbstractDBElement) {
return $this->entityURLGenerator->timeTravelURL($target, $context->getTimestamp());
}
- } catch (EntityNotSupportedException $exception) {
+ } catch (EntityNotSupportedException) {
return null;
}
}
return null;
},
- 'disabled' => function ($value, AbstractLogEntry $context) {
- return !$this->security->isGranted('show_history', $context->getTargetClass());
- },
+ 'disabled' => fn($value, AbstractLogEntry $context) => !$this->security->isGranted('show_history', $context->getTargetClass()),
]);
$dataTable->add('actionRevert', RevertLogColumn::class, [
@@ -338,8 +279,8 @@ class LogDataTable implements DataTableTypeInterface
->andWhere('log.target_type NOT IN (:disallowed)');
$builder->setParameter('disallowed', [
- AbstractLogEntry::targetTypeClassToID(User::class),
- AbstractLogEntry::targetTypeClassToID(Group::class),
+ LogTargetType::USER,
+ LogTargetType::GROUP,
]);
}
@@ -347,9 +288,16 @@ class LogDataTable implements DataTableTypeInterface
foreach ($options['filter_elements'] as $element) {
/** @var AbstractDBElement $element */
- $target_type = AbstractLogEntry::targetTypeClassToID(get_class($element));
+ $target_type = LogTargetType::fromElementClass($element);
$target_id = $element->getID();
- $builder->orWhere("log.target_type = ${target_type} AND log.target_id = ${target_id}");
+
+ //We have to create unique parameter names for each element
+ $target_type_var = 'filter_target_type_' . uniqid('', false);
+ $target_id_var = 'filter_target_id_' . uniqid('', false);
+
+ $builder->orWhere("log.target_type = :$target_type_var AND log.target_id = :$target_id_var");
+ $builder->setParameter($target_type_var, $target_type);
+ $builder->setParameter($target_id_var, $target_id);
}
}
}
diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php
index f38215e3..3163a38b 100644
--- a/src/DataTables/PartsDataTable.php
+++ b/src/DataTables/PartsDataTable.php
@@ -22,7 +22,9 @@ declare(strict_types=1);
namespace App\DataTables;
+use App\DataTables\Adapters\TwoStepORMAdapter;
use App\DataTables\Column\EntityColumn;
+use App\DataTables\Column\EnumColumn;
use App\DataTables\Column\IconLinkColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
@@ -34,57 +36,38 @@ use App\DataTables\Column\SIUnitNumberColumn;
use App\DataTables\Column\TagsColumn;
use App\DataTables\Filters\PartFilter;
use App\DataTables\Filters\PartSearchFilter;
+use App\DataTables\Helpers\ColumnSortHelper;
use App\DataTables\Helpers\PartDataTableHelper;
-use App\Entity\Parts\Category;
-use App\Entity\Parts\Footprint;
-use App\Entity\Parts\Manufacturer;
+use App\Doctrine\Helpers\FieldHelper;
+use App\Entity\Parts\ManufacturingStatus;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
-use App\Entity\Parts\Storelocation;
-use App\Entity\Parts\Supplier;
-use App\Services\Formatters\AmountFormatter;
-use App\Services\Attachments\AttachmentURLGenerator;
-use App\Services\Attachments\PartPreviewGenerator;
+use App\Entity\ProjectSystem\Project;
use App\Services\EntityURLGenerator;
-use App\Services\Trees\NodesListBuilder;
+use App\Services\Formatters\AmountFormatter;
+use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\QueryBuilder;
-use Omines\DataTablesBundle\Adapter\Doctrine\FetchJoinORMAdapter;
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
-use Omines\DataTablesBundle\Column\BoolColumn;
-use Omines\DataTablesBundle\Column\MapColumn;
use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface;
+use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\OptionsResolver\OptionsResolver;
-use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
final class PartsDataTable implements DataTableTypeInterface
{
- private TranslatorInterface $translator;
- private NodesListBuilder $treeBuilder;
- private AmountFormatter $amountFormatter;
- private AttachmentURLGenerator $attachmentURLGenerator;
- private Security $security;
+ const LENGTH_MENU = [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]];
- private PartDataTableHelper $partDataTableHelper;
-
- /**
- * @var EntityURLGenerator
- */
- private $urlGenerator;
-
- public function __construct(EntityURLGenerator $urlGenerator, TranslatorInterface $translator,
- NodesListBuilder $treeBuilder, AmountFormatter $amountFormatter,PartDataTableHelper $partDataTableHelper,
- AttachmentURLGenerator $attachmentURLGenerator, Security $security)
- {
- $this->urlGenerator = $urlGenerator;
- $this->translator = $translator;
- $this->treeBuilder = $treeBuilder;
- $this->amountFormatter = $amountFormatter;
- $this->attachmentURLGenerator = $attachmentURLGenerator;
- $this->security = $security;
- $this->partDataTableHelper = $partDataTableHelper;
+ public function __construct(
+ private readonly EntityURLGenerator $urlGenerator,
+ private readonly TranslatorInterface $translator,
+ private readonly AmountFormatter $amountFormatter,
+ private readonly PartDataTableHelper $partDataTableHelper,
+ private readonly Security $security,
+ private readonly string $visible_columns,
+ private readonly ColumnSortHelper $csh,
+ ) {
}
public function configureOptions(OptionsResolver $optionsResolver): void
@@ -104,10 +87,10 @@ final class PartsDataTable implements DataTableTypeInterface
$this->configureOptions($resolver);
$options = $resolver->resolve($options);
- $dataTable
+ $this->csh
//Color the table rows depending on the review and favorite status
- ->add('dont_matter', RowClassColumn::class, [
- 'render' => function ($value, Part $context) {
+ ->add('row_color', RowClassColumn::class, [
+ 'render' => function ($value, Part $context): string {
if ($context->isNeedsReview()) {
return 'table-secondary';
}
@@ -117,189 +100,208 @@ final class PartsDataTable implements DataTableTypeInterface
return ''; //Default coloring otherwise
},
- ])
-
- ->add('select', SelectColumn::class)
+ ], visibility_configurable: false)
+ ->add('select', SelectColumn::class, visibility_configurable: false)
->add('picture', TextColumn::class, [
'label' => '',
'className' => 'no-colvis',
- 'render' => function ($value, Part $context) {
- return $this->partDataTableHelper->renderPicture($context);
- },
- ])
+ 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderPicture($context),
+ ], visibility_configurable: false)
->add('name', TextColumn::class, [
'label' => $this->translator->trans('part.table.name'),
- 'render' => function ($value, Part $context) {
- return $this->partDataTableHelper->renderName($context);
- },
+ 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context),
+ 'orderField' => 'NATSORT(part.name)'
])
->add('id', TextColumn::class, [
'label' => $this->translator->trans('part.table.id'),
- 'visible' => false,
])
->add('ipn', TextColumn::class, [
'label' => $this->translator->trans('part.table.ipn'),
- 'visible' => false,
+ 'orderField' => 'NATSORT(part.ipn)'
])
->add('description', MarkdownColumn::class, [
'label' => $this->translator->trans('part.table.description'),
- ]);
-
- if ($this->security->isGranted('@categories.read')) {
- $dataTable->add('category', EntityColumn::class, [
+ ])
+ ->add('category', EntityColumn::class, [
'label' => $this->translator->trans('part.table.category'),
'property' => 'category',
- ]);
- }
-
- if ($this->security->isGranted('@footprints.read')) {
- $dataTable->add('footprint', EntityColumn::class, [
+ 'orderField' => 'NATSORT(_category.name)'
+ ])
+ ->add('footprint', EntityColumn::class, [
'property' => 'footprint',
'label' => $this->translator->trans('part.table.footprint'),
- ]);
- }
- if ($this->security->isGranted('@manufacturers.read')) {
- $dataTable->add('manufacturer', EntityColumn::class, [
+ 'orderField' => 'NATSORT(_footprint.name)'
+ ])
+ ->add('manufacturer', EntityColumn::class, [
'property' => 'manufacturer',
'label' => $this->translator->trans('part.table.manufacturer'),
- ]);
- }
- if ($this->security->isGranted('@storelocations.read')) {
- $dataTable->add('storelocation', TextColumn::class, [
+ 'orderField' => 'NATSORT(_manufacturer.name)'
+ ])
+ ->add('storelocation', TextColumn::class, [
'label' => $this->translator->trans('part.table.storeLocations'),
- 'render' => function ($value, Part $context) {
- $tmp = [];
- foreach ($context->getPartLots() as $lot) {
- //Ignore lots without storelocation
- if (null === $lot->getStorageLocation()) {
- continue;
- }
- $tmp[] = sprintf(
- '%s',
- $this->urlGenerator->listPartsURL($lot->getStorageLocation()),
- $lot->getStorageLocation()->getName()
- );
- }
+ //We need to use a aggregate function to get the first store location, as we have a one-to-many relation
+ 'orderField' => 'NATSORT(MIN(_storelocations.name))',
+ 'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
+ ], alias: 'storage_location')
- return implode(' ', $tmp);
- },
- ]);
- }
-
- $dataTable->add('amount', TextColumn::class, [
- 'label' => $this->translator->trans('part.table.amount'),
- 'render' => function ($value, Part $context) {
- $amount = $context->getAmountSum();
- $expiredAmount = $context->getExpiredAmountSum();
-
- $ret = $this->amountFormatter->format($amount, $context->getPartUnit());
-
- //If we have expired lots, we show them in parentheses behind
- if ($expiredAmount > 0) {
- $ret .= sprintf(' (+%s)',
- $this->translator->trans('part_lots.is_expired'),
- $this->amountFormatter->format($expiredAmount, $context->getPartUnit()));
- }
-
-
- return $ret;
- },
- 'orderField' => 'amountSum'
- ])
+ ->add('amount', TextColumn::class, [
+ 'label' => $this->translator->trans('part.table.amount'),
+ 'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderAmount($context),
+ 'orderField' => 'amountSum'
+ ])
->add('minamount', TextColumn::class, [
'label' => $this->translator->trans('part.table.minamount'),
- 'visible' => false,
- 'render' => function ($value, Part $context) {
- return $this->amountFormatter->format($value, $context->getPartUnit());
- },
- ]);
-
- if ($this->security->isGranted('@footprints.read')) {
- $dataTable->add('partUnit', TextColumn::class, [
- 'field' => 'partUnit.name',
+ 'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format($value,
+ $context->getPartUnit())),
+ ])
+ ->add('partUnit', TextColumn::class, [
'label' => $this->translator->trans('part.table.partUnit'),
- 'visible' => false,
- ]);
- }
+ 'orderField' => 'NATSORT(_partUnit.name)',
+ 'render' => function($value, Part $context): string {
+ $partUnit = $context->getPartUnit();
+ if ($partUnit === null) {
+ return '';
+ }
- $dataTable->add('addedDate', LocaleDateTimeColumn::class, [
- 'label' => $this->translator->trans('part.table.addedDate'),
- 'visible' => false,
- ])
+ $tmp = htmlspecialchars($partUnit->getName());
+
+ if ($partUnit->getUnit()) {
+ $tmp .= ' ('.htmlspecialchars($partUnit->getUnit()).')';
+ }
+ return $tmp;
+ }
+ ])
+ ->add('addedDate', LocaleDateTimeColumn::class, [
+ 'label' => $this->translator->trans('part.table.addedDate'),
+ ])
->add('lastModified', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('part.table.lastModified'),
- 'visible' => false,
])
->add('needs_review', PrettyBoolColumn::class, [
'label' => $this->translator->trans('part.table.needsReview'),
- 'visible' => false,
])
->add('favorite', PrettyBoolColumn::class, [
'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 === null) {
+ return '';
+ }
+
+ return $this->translator->trans($status->toTranslationKey());
+ },
])
->add('manufacturer_product_number', TextColumn::class, [
'label' => $this->translator->trans('part.table.mpn'),
- 'visible' => false,
+ 'orderField' => 'NATSORT(part.manufacturer_product_number)'
])
->add('mass', SIUnitNumberColumn::class, [
'label' => $this->translator->trans('part.table.mass'),
- 'visible' => false,
'unit' => 'g'
])
->add('tags', TagsColumn::class, [
'label' => $this->translator->trans('part.table.tags'),
- 'visible' => false,
])
->add('attachments', PartAttachmentsColumn::class, [
'label' => $this->translator->trans('part.table.attachments'),
- 'visible' => false,
- ])
+ ]);
+
+ //Add a column to list the projects where the part is used, when the user has the permission to see the projects
+ if ($this->security->isGranted('read', Project::class)) {
+ $this->csh->add('projects', TextColumn::class, [
+ 'label' => $this->translator->trans('project.labelp'),
+ 'render' => function ($value, Part $context): string {
+ //Only show the first 5 projects names
+ $projects = $context->getProjects();
+ $tmp = "";
+
+ $max = 5;
+
+ for ($i = 0; $i < min($max, count($projects)); $i++) {
+ $url = $this->urlGenerator->infoURL($projects[$i]);
+ $tmp .= sprintf('%s', $url, htmlspecialchars($projects[$i]->getName()));
+ if ($i < count($projects) - 1) {
+ $tmp .= ", ";
+ }
+ }
+
+ if (count($projects) > $max) {
+ $tmp .= ", + ".(count($projects) - $max);
+ }
+
+ return $tmp;
+ }
+ ]);
+ }
+
+ $this->csh
->add('edit', IconLinkColumn::class, [
'label' => $this->translator->trans('part.table.edit'),
- 'visible' => false,
- 'href' => function ($value, Part $context) {
- return $this->urlGenerator->editURL($context);
- },
- 'disabled' => function ($value, Part $context) {
- return !$this->security->isGranted('edit', $context);
- },
+ 'href' => fn($value, Part $context) => $this->urlGenerator->editURL($context),
+ 'disabled' => fn($value, Part $context) => !$this->security->isGranted('edit', $context),
'title' => $this->translator->trans('part.table.edit.title'),
- ])
+ ]);
- ->addOrderBy('name')
- ->createAdapter(FetchJoinORMAdapter::class, [
- 'simple_total_query' => true,
- 'query' => function (QueryBuilder $builder): void {
- $this->getQuery($builder);
- },
+ //Apply the user configured order and visibility and add the columns to the table
+ $this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->visible_columns,
+ "TABLE_PARTS_DEFAULT_COLUMNS");
+
+ $dataTable->addOrderBy('name')
+ ->createAdapter(TwoStepORMAdapter::class, [
+ 'filter_query' => $this->getFilterQuery(...),
+ 'detail_query' => $this->getDetailQuery(...),
'entity' => Part::class,
+ 'hydrate' => AbstractQuery::HYDRATE_OBJECT,
+ //Use the simple total query, as we just want to get the total number of parts without any conditions
+ //For this the normal query would be pretty slow
+ 'simple_total_query' => true,
'criteria' => [
function (QueryBuilder $builder) use ($options): void {
$this->buildCriteria($builder, $options);
},
new SearchCriteriaProvider(),
],
+ 'query_modifier' => $this->addJoins(...),
]);
}
- private function getQuery(QueryBuilder $builder): void
+
+ private function getFilterQuery(QueryBuilder $builder): void
{
- //Distinct is very slow here, do not add this here (also I think this is not needed here, as the id column is always distinct)
- $builder->select('part')
+ /* In the filter query we only select the IDs. The fetching of the full entities is done in the detail query.
+ * We only need to join the entities here, so we can filter by them.
+ * The filter conditions are added to this QB in the buildCriteria method.
+ *
+ * The amountSum field and the joins are dynmically added by the addJoins method, if the fields are used in the query.
+ * This improves the performance, as we do not need to join all tables, if we do not need them.
+ */
+ $builder
+ ->select('part.id')
+ ->addSelect('part.minamount AS HIDDEN minamount')
+ ->from(Part::class, 'part')
+
+ //The other group by fields, are dynamically added by the addJoins method
+ ->addGroupBy('part');
+ }
+
+ private function getDetailQuery(QueryBuilder $builder, array $filter_results): void
+ {
+ $ids = array_map(static fn($row) => $row['id'], $filter_results);
+
+ /*
+ * In this query we take the IDs which were filtered, paginated and sorted in the filter query, and fetch the
+ * full entities.
+ * We can do complex fetch joins, as we do not need to filter or sort here (which would kill the performance).
+ * The only condition should be for the IDs.
+ * It is important that elements are ordered the same way, as the IDs are passed, or ordering will be wrong.
+ *
+ * We do not require the subqueries like amountSum here, as it is not used to render the table (and only for sorting)
+ */
+ $builder
+ ->select('part')
->addSelect('category')
->addSelect('footprint')
->addSelect('manufacturer')
@@ -310,16 +312,6 @@ final class PartsDataTable implements DataTableTypeInterface
->addSelect('orderdetails')
->addSelect('attachments')
->addSelect('storelocations')
- //Calculate amount sum using a subquery, so we can filter and sort by it
- ->addSelect(
- '(
- SELECT IFNULL(SUM(partLot.amount), 0.0)
- FROM '. PartLot::class. ' partLot
- WHERE partLot.part = part.id
- AND partLot.instock_unknown = false
- AND (partLot.expiration_date IS NULL OR partLot.expiration_date > CURRENT_DATE())
- ) AS HIDDEN amountSum'
- )
->from(Part::class, 'part')
->leftJoin('part.category', 'category')
->leftJoin('part.master_picture_attachment', 'master_picture_attachment')
@@ -333,6 +325,8 @@ final class PartsDataTable implements DataTableTypeInterface
->leftJoin('part.attachments', 'attachments')
->leftJoin('part.partUnit', 'partUnit')
->leftJoin('part.parameters', 'parameters')
+ ->where('part.id IN (:ids)')
+ ->setParameter('ids', $ids)
//We have to group by all elements, or only the first sub elements of an association is fetched! (caused issue #190)
->addGroupBy('part')
@@ -347,8 +341,89 @@ final class PartsDataTable implements DataTableTypeInterface
->addGroupBy('suppliers')
->addGroupBy('attachments')
->addGroupBy('partUnit')
- ->addGroupBy('parameters')
- ;
+ ->addGroupBy('parameters');
+
+ //Get the results in the same order as the IDs were passed
+ FieldHelper::addOrderByFieldParam($builder, 'part.id', 'ids');
+ }
+
+ /**
+ * This function is called right before the filter query is executed.
+ * We use it to dynamically add joins to the query, if the fields are used in the query.
+ * @param QueryBuilder $builder
+ * @return QueryBuilder
+ */
+ private function addJoins(QueryBuilder $builder): QueryBuilder
+ {
+ //Check if the query contains certain conditions, for which we need to add additional joins
+ //The join fields get prefixed with an underscore, so we can check if they are used in the query easy without confusing them for a part subfield
+ $dql = $builder->getDQL();
+
+ //Add the amountSum field, if it is used in the query
+ if (str_contains($dql, 'amountSum')) {
+ //Calculate amount sum using a subquery, so we can filter and sort by it
+ $builder->addSelect(
+ '(
+ SELECT COALESCE(SUM(partLot.amount), 0.0)
+ FROM '.PartLot::class.' partLot
+ WHERE partLot.part = part.id
+ AND partLot.instock_unknown = false
+ AND (partLot.expiration_date IS NULL OR partLot.expiration_date > CURRENT_DATE())
+ ) AS HIDDEN amountSum'
+ );
+ }
+
+ if (str_contains($dql, '_category')) {
+ $builder->leftJoin('part.category', '_category');
+ $builder->addGroupBy('_category');
+ }
+ if (str_contains($dql, '_master_picture_attachment')) {
+ $builder->leftJoin('part.master_picture_attachment', '_master_picture_attachment');
+ $builder->addGroupBy('_master_picture_attachment');
+ }
+ if (str_contains($dql, '_partLots') || str_contains($dql, '_storelocations')) {
+ $builder->leftJoin('part.partLots', '_partLots');
+ $builder->leftJoin('_partLots.storage_location', '_storelocations');
+ //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1
+ //$builder->addGroupBy('_partLots');
+ //$builder->addGroupBy('_storelocations');
+ }
+ if (str_contains($dql, '_footprint')) {
+ $builder->leftJoin('part.footprint', '_footprint');
+ $builder->addGroupBy('_footprint');
+ }
+ if (str_contains($dql, '_manufacturer')) {
+ $builder->leftJoin('part.manufacturer', '_manufacturer');
+ $builder->addGroupBy('_manufacturer');
+ }
+ if (str_contains($dql, '_orderdetails') || str_contains($dql, '_suppliers')) {
+ $builder->leftJoin('part.orderdetails', '_orderdetails');
+ $builder->leftJoin('_orderdetails.supplier', '_suppliers');
+ //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1
+ //$builder->addGroupBy('_orderdetails');
+ //$builder->addGroupBy('_suppliers');
+ }
+ if (str_contains($dql, '_attachments')) {
+ $builder->leftJoin('part.attachments', '_attachments');
+ //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1
+ //$builder->addGroupBy('_attachments');
+ }
+ if (str_contains($dql, '_partUnit')) {
+ $builder->leftJoin('part.partUnit', '_partUnit');
+ $builder->addGroupBy('_partUnit');
+ }
+ if (str_contains($dql, '_parameters')) {
+ $builder->leftJoin('part.parameters', '_parameters');
+ //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1
+ //$builder->addGroupBy('_parameters');
+ }
+ if (str_contains($dql, '_projectBomEntries')) {
+ $builder->leftJoin('part.project_bom_entries', '_projectBomEntries');
+ //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1
+ //$builder->addGroupBy('_projectBomEntries');
+ }
+
+ return $builder;
}
private function buildCriteria(QueryBuilder $builder, array $options): void
@@ -364,6 +439,5 @@ final class PartsDataTable implements DataTableTypeInterface
$filter = $options['filter'];
$filter->apply($builder);
}
-
}
}
diff --git a/src/DataTables/ProjectBomEntriesDataTable.php b/src/DataTables/ProjectBomEntriesDataTable.php
index 8d7c839d..fcb06984 100644
--- a/src/DataTables/ProjectBomEntriesDataTable.php
+++ b/src/DataTables/ProjectBomEntriesDataTable.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\DataTables;
use App\DataTables\Column\EntityColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
-use App\DataTables\Column\SelectColumn;
use App\DataTables\Helpers\PartDataTableHelper;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Part;
@@ -40,22 +41,12 @@ use Symfony\Contracts\Translation\TranslatorInterface;
class ProjectBomEntriesDataTable implements DataTableTypeInterface
{
- protected TranslatorInterface $translator;
- protected PartDataTableHelper $partDataTableHelper;
- protected EntityURLGenerator $entityURLGenerator;
- protected AmountFormatter $amountFormatter;
-
- public function __construct(TranslatorInterface $translator, PartDataTableHelper $partDataTableHelper,
- EntityURLGenerator $entityURLGenerator, AmountFormatter $amountFormatter)
+ public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper, protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter)
{
- $this->translator = $translator;
- $this->partDataTableHelper = $partDataTableHelper;
- $this->entityURLGenerator = $entityURLGenerator;
- $this->amountFormatter = $amountFormatter;
}
- public function configure(DataTable $dataTable, array $options)
+ public function configure(DataTable $dataTable, array $options): void
{
$dataTable
//->add('select', SelectColumn::class)
@@ -63,7 +54,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
'label' => '',
'className' => 'no-colvis',
'render' => function ($value, ProjectBOMEntry $context) {
- if($context->getPart() === null) {
+ if(!$context->getPart() instanceof Part) {
return '';
}
return $this->partDataTableHelper->renderPicture($context->getPart());
@@ -78,38 +69,48 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
->add('quantity', TextColumn::class, [
'label' => $this->translator->trans('project.bom.quantity'),
'className' => 'text-center',
- 'render' => function ($value, ProjectBOMEntry $context) {
+ 'orderField' => 'bom_entry.quantity',
+ 'render' => function ($value, ProjectBOMEntry $context): float|string {
//If we have a non-part entry, only show the rounded quantity
- if ($context->getPart() === null) {
+ if (!$context->getPart() instanceof Part) {
return round($context->getQuantity());
}
//Otherwise use the unit of the part to format the quantity
- return $this->amountFormatter->format($context->getQuantity(), $context->getPart()->getPartUnit());
+ return htmlspecialchars($this->amountFormatter->format($context->getQuantity(), $context->getPart()->getPartUnit()));
},
])
->add('name', TextColumn::class, [
'label' => $this->translator->trans('part.table.name'),
- 'orderable' => false,
+ 'orderField' => 'NATSORT(part.name)',
'render' => function ($value, ProjectBOMEntry $context) {
- if($context->getPart() === null) {
- return $context->getName();
+ if(!$context->getPart() instanceof Part) {
+ return htmlspecialchars((string) $context->getName());
}
- if($context->getPart() !== null) {
- $tmp = $this->partDataTableHelper->renderName($context->getPart());
- if(!empty($context->getName())) {
- $tmp .= ' '.htmlspecialchars($context->getName()).'';
- }
- return $tmp;
+
+ //Part exists if we reach this point
+
+ $tmp = $this->partDataTableHelper->renderName($context->getPart());
+ if($context->getName() !== null && $context->getName() !== '') {
+ $tmp .= ' '.htmlspecialchars($context->getName()).'';
}
- throw new \Exception('This should never happen!');
+ return $tmp;
},
])
-
+ ->add('ipn', TextColumn::class, [
+ 'label' => $this->translator->trans('part.table.ipn'),
+ 'orderField' => 'NATSORT(part.ipn)',
+ 'visible' => false,
+ 'render' => function ($value, ProjectBOMEntry $context) {
+ if($context->getPart() instanceof Part) {
+ return $context->getPart()->getIpn();
+ }
+ }
+ ])
->add('description', MarkdownColumn::class, [
'label' => $this->translator->trans('part.table.description'),
'data' => function (ProjectBOMEntry $context) {
- if($context->getPart() !== null) {
+ if($context->getPart() instanceof Part) {
return $context->getPart()->getDescription();
}
//For non-part BOM entries show the comment field
@@ -121,15 +122,18 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
->add('category', EntityColumn::class, [
'label' => $this->translator->trans('part.table.category'),
'property' => 'part.category',
+ 'orderField' => 'NATSORT(category.name)',
])
->add('footprint', EntityColumn::class, [
'property' => 'part.footprint',
'label' => $this->translator->trans('part.table.footprint'),
+ 'orderField' => 'NATSORT(footprint.name)',
])
->add('manufacturer', EntityColumn::class, [
'property' => 'part.manufacturer',
'label' => $this->translator->trans('part.table.manufacturer'),
+ 'orderField' => 'NATSORT(manufacturer.name)',
])
->add('mountnames', TextColumn::class, [
@@ -144,6 +148,28 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
},
])
+ ->add('instockAmount', TextColumn::class, [
+ 'label' => 'project.bom.instockAmount',
+ 'visible' => false,
+ 'render' => function ($value, ProjectBOMEntry $context) {
+ if ($context->getPart() !== null) {
+ return $this->partDataTableHelper->renderAmount($context->getPart());
+ }
+
+ return '';
+ }
+ ])
+ ->add('storageLocations', TextColumn::class, [
+ 'label' => 'part.table.storeLocations',
+ 'visible' => false,
+ 'render' => function ($value, ProjectBOMEntry $context) {
+ if ($context->getPart() !== null) {
+ return $this->partDataTableHelper->renderStorageLocations($context->getPart());
+ }
+
+ return '';
+ }
+ ])
->add('addedDate', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('part.table.addedDate'),
@@ -155,6 +181,8 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
])
;
+ $dataTable->addOrderBy('name', DataTable::SORT_ASCENDING);
+
$dataTable->createAdapter(ORMAdapter::class, [
'entity' => Attachment::class,
'query' => function (QueryBuilder $builder) use ($options): void {
@@ -175,6 +203,9 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
->addSelect('part')
->from(ProjectBOMEntry::class, 'bom_entry')
->leftJoin('bom_entry.part', 'part')
+ ->leftJoin('part.category', 'category')
+ ->leftJoin('part.footprint', 'footprint')
+ ->leftJoin('part.manufacturer', 'manufacturer')
->where('bom_entry.project = :project')
->setParameter('project', $options['project'])
;
@@ -184,4 +215,4 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
{
}
-}
\ No newline at end of file
+}
diff --git a/src/Doctrine/Functions/ArrayPosition.php b/src/Doctrine/Functions/ArrayPosition.php
new file mode 100644
index 00000000..39276912
--- /dev/null
+++ b/src/Doctrine/Functions/ArrayPosition.php
@@ -0,0 +1,59 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Doctrine\Functions;
+
+use Doctrine\ORM\Query\AST\Functions\FunctionNode;
+use Doctrine\ORM\Query\AST\Node;
+use Doctrine\ORM\Query\Parser;
+use Doctrine\ORM\Query\SqlWalker;
+use Doctrine\ORM\Query\TokenType;
+
+class ArrayPosition extends FunctionNode
+{
+ private ?Node $array = null;
+
+ private ?Node $field = null;
+
+ public function parse(Parser $parser): void
+ {
+ $parser->match(TokenType::T_IDENTIFIER);
+ $parser->match(TokenType::T_OPEN_PARENTHESIS);
+
+ $this->array = $parser->InParameter();
+
+ $parser->match(TokenType::T_COMMA);
+
+ $this->field = $parser->ArithmeticPrimary();
+
+ $parser->match(TokenType::T_CLOSE_PARENTHESIS);
+ }
+
+ public function getSql(SqlWalker $sqlWalker): string
+ {
+ return 'ARRAY_POSITION(' .
+ $this->array->dispatch($sqlWalker) . ', ' .
+ $this->field->dispatch($sqlWalker) .
+ ')';
+ }
+}
\ No newline at end of file
diff --git a/src/Doctrine/Functions/Field2.php b/src/Doctrine/Functions/Field2.php
new file mode 100644
index 00000000..57f55653
--- /dev/null
+++ b/src/Doctrine/Functions/Field2.php
@@ -0,0 +1,82 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Doctrine\Functions;
+
+use Doctrine\ORM\Query\Parser;
+use Doctrine\ORM\Query\SqlWalker;
+use Doctrine\ORM\Query\AST\Functions\FunctionNode;
+use Doctrine\ORM\Query\TokenType;
+
+/**
+ * Basically the same as the original Field function, but uses FIELD2 for the SQL query.
+ */
+class Field2 extends FunctionNode
+{
+
+ private $field = null;
+
+ private $values = [];
+
+ public function parse(Parser $parser): void
+ {
+ $parser->match(TokenType::T_IDENTIFIER);
+ $parser->match(TokenType::T_OPEN_PARENTHESIS);
+
+ // Do the field.
+ $this->field = $parser->ArithmeticPrimary();
+
+ // Add the strings to the values array. FIELD must
+ // be used with at least 1 string not including the field.
+
+ $lexer = $parser->getLexer();
+
+ while (count($this->values) < 1 ||
+ $lexer->lookahead->type !== TokenType::T_CLOSE_PARENTHESIS) {
+ $parser->match(TokenType::T_COMMA);
+ $this->values[] = $parser->ArithmeticPrimary();
+ }
+
+ $parser->match(TokenType::T_CLOSE_PARENTHESIS);
+ }
+
+ public function getSql(SqlWalker $sqlWalker): string
+ {
+ $query = 'FIELD2(';
+
+ $query .= $this->field->dispatch($sqlWalker);
+
+ $query .= ', ';
+ $counter = count($this->values);
+
+ for ($i = 0; $i < $counter; $i++) {
+ if ($i > 0) {
+ $query .= ', ';
+ }
+
+ $query .= $this->values[$i]->dispatch($sqlWalker);
+ }
+
+ return $query . ')';
+ }
+}
\ No newline at end of file
diff --git a/src/Doctrine/Functions/ILike.php b/src/Doctrine/Functions/ILike.php
new file mode 100644
index 00000000..5246220a
--- /dev/null
+++ b/src/Doctrine/Functions/ILike.php
@@ -0,0 +1,71 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Doctrine\Functions;
+
+use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
+use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
+use Doctrine\DBAL\Platforms\SQLitePlatform;
+use Doctrine\ORM\Query\AST\Functions\FunctionNode;
+use Doctrine\ORM\Query\Parser;
+use Doctrine\ORM\Query\SqlWalker;
+use Doctrine\ORM\Query\TokenType;
+
+/**
+ * A platform invariant version of the case-insensitive LIKE operation.
+ * On MySQL and SQLite this is the normal LIKE, but on PostgreSQL it is the ILIKE operator.
+ */
+class ILike extends FunctionNode
+{
+
+ public $value = null;
+
+ public $expr = null;
+
+ public function parse(Parser $parser): void
+ {
+ $parser->match(TokenType::T_IDENTIFIER);
+ $parser->match(TokenType::T_OPEN_PARENTHESIS);
+ $this->value = $parser->StringPrimary();
+ $parser->match(TokenType::T_COMMA);
+ $this->expr = $parser->StringExpression();
+ $parser->match(TokenType::T_CLOSE_PARENTHESIS);
+ }
+
+ public function getSql(SqlWalker $sqlWalker): string
+ {
+ $platform = $sqlWalker->getConnection()->getDatabasePlatform();
+
+ //
+ if ($platform instanceof AbstractMySQLPlatform || $platform instanceof SQLitePlatform) {
+ $operator = 'LIKE';
+ } elseif ($platform instanceof PostgreSQLPlatform) {
+ //Use the case-insensitive operator, to have the same behavior as MySQL
+ $operator = 'ILIKE';
+ } else {
+ throw new \RuntimeException('Platform ' . gettype($platform) . ' does not support case insensitive like expressions.');
+ }
+
+ return '(' . $this->value->dispatch($sqlWalker) . ' ' . $operator . ' ' . $this->expr->dispatch($sqlWalker) . ')';
+ }
+}
\ No newline at end of file
diff --git a/src/Doctrine/Functions/Natsort.php b/src/Doctrine/Functions/Natsort.php
new file mode 100644
index 00000000..bd05e0d6
--- /dev/null
+++ b/src/Doctrine/Functions/Natsort.php
@@ -0,0 +1,151 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Doctrine\Functions;
+
+use Doctrine\DBAL\Exception;
+use Doctrine\DBAL\Connection;
+use Doctrine\DBAL\Driver\AbstractPostgreSQLDriver;
+use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
+use Doctrine\DBAL\Platforms\MariaDBPlatform;
+use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
+use Doctrine\DBAL\Platforms\SQLitePlatform;
+use Doctrine\ORM\Query\AST\Functions\FunctionNode;
+use Doctrine\ORM\Query\AST\Node;
+use Doctrine\ORM\Query\Parser;
+use Doctrine\ORM\Query\SqlWalker;
+use Doctrine\ORM\Query\TokenType;
+
+class Natsort extends FunctionNode
+{
+ private ?Node $field = null;
+
+ private static ?bool $supportsNaturalSort = null;
+
+ private static bool $allowSlowNaturalSort = false;
+
+ /**
+ * As we can not inject parameters into the function, we use an event listener, to call the value on the static function.
+ * This is the only way to inject the value into the function.
+ * @param bool $allow
+ * @return void
+ */
+ public static function allowSlowNaturalSort(bool $allow = true): void
+ {
+ self::$allowSlowNaturalSort = $allow;
+ }
+
+ /**
+ * Check if the slow natural sort is allowed
+ * @return bool
+ */
+ public static function isSlowNaturalSortAllowed(): bool
+ {
+ return self::$allowSlowNaturalSort;
+ }
+
+ /**
+ * Check if the MariaDB version which is connected to supports the natural sort (meaning it has a version of 10.7.0 or higher)
+ * The result is cached in memory.
+ * @param Connection $connection
+ * @return bool
+ * @throws Exception
+ */
+ private function mariaDBSupportsNaturalSort(Connection $connection): bool
+ {
+ if (self::$supportsNaturalSort !== null) {
+ return self::$supportsNaturalSort;
+ }
+
+ $version = $connection->getServerVersion();
+
+ //Get the effective MariaDB version number
+ $version = $this->getMariaDbMysqlVersionNumber($version);
+
+ //We need at least MariaDB 10.7.0 to support the natural sort
+ self::$supportsNaturalSort = version_compare($version, '10.7.0', '>=');
+ return self::$supportsNaturalSort;
+ }
+
+ /**
+ * Taken from Doctrine\DBAL\Driver\AbstractMySQLDriver
+ *
+ * Detect MariaDB server version, including hack for some mariadb distributions
+ * that starts with the prefix '5.5.5-'
+ *
+ * @param string $versionString Version string as returned by mariadb server, i.e. '5.5.5-Mariadb-10.0.8-xenial'
+ */
+ private function getMariaDbMysqlVersionNumber(string $versionString) : string
+ {
+ if ( ! preg_match(
+ '/^(?:5\.5\.5-)?(mariadb-)?(?P\d+)\.(?P\d+)\.(?P\d+)/i',
+ $versionString,
+ $versionParts
+ )) {
+ throw new \RuntimeException('Could not detect MariaDB version from version string ' . $versionString);
+ }
+
+ return $versionParts['major'] . '.' . $versionParts['minor'] . '.' . $versionParts['patch'];
+ }
+
+ public function parse(Parser $parser): void
+ {
+ $parser->match(TokenType::T_IDENTIFIER);
+ $parser->match(TokenType::T_OPEN_PARENTHESIS);
+
+ $this->field = $parser->ArithmeticExpression();
+
+ $parser->match(TokenType::T_CLOSE_PARENTHESIS);
+ }
+
+ public function getSql(SqlWalker $sqlWalker): string
+ {
+ assert($this->field !== null, 'Field is not set');
+
+ $platform = $sqlWalker->getConnection()->getDatabasePlatform();
+
+ if ($platform instanceof PostgreSQLPlatform) {
+ return $this->field->dispatch($sqlWalker) . ' COLLATE numeric';
+ }
+
+ if ($platform instanceof MariaDBPlatform && $this->mariaDBSupportsNaturalSort($sqlWalker->getConnection())) {
+ return 'NATURAL_SORT_KEY(' . $this->field->dispatch($sqlWalker) . ')';
+ }
+
+ //Do the following operations only if we allow slow natural sort
+ if (self::$allowSlowNaturalSort) {
+ if ($platform instanceof SQLitePlatform) {
+ return $this->field->dispatch($sqlWalker).' COLLATE NATURAL_CMP';
+ }
+
+ if ($platform instanceof AbstractMySQLPlatform) {
+ return 'NatSortKey(' . $this->field->dispatch($sqlWalker) . ', 0)';
+ }
+ }
+
+ //For every other platform, return the field as is
+ return $this->field->dispatch($sqlWalker);
+ }
+
+
+}
\ No newline at end of file
diff --git a/src/Doctrine/Functions/Regexp.php b/src/Doctrine/Functions/Regexp.php
new file mode 100644
index 00000000..d7c6f1e7
--- /dev/null
+++ b/src/Doctrine/Functions/Regexp.php
@@ -0,0 +1,52 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Doctrine\Functions;
+
+use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
+use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
+use Doctrine\DBAL\Platforms\SQLitePlatform;
+use Doctrine\ORM\Query\SqlWalker;
+
+/**
+ * Similar to the regexp function, but with support for multi platform.
+ */
+class Regexp extends \DoctrineExtensions\Query\Mysql\Regexp
+{
+ public function getSql(SqlWalker $sqlWalker): string
+ {
+ $platform = $sqlWalker->getConnection()->getDatabasePlatform();
+
+ //
+ if ($platform instanceof AbstractMySQLPlatform || $platform instanceof SQLitePlatform) {
+ $operator = 'REGEXP';
+ } elseif ($platform instanceof PostgreSQLPlatform) {
+ //Use the case-insensitive operator, to have the same behavior as MySQL
+ $operator = '~*';
+ } else {
+ throw new \RuntimeException('Platform ' . gettype($platform) . ' does not support regular expressions.');
+ }
+
+ return '(' . $this->value->dispatch($sqlWalker) . ' ' . $operator . ' ' . $this->regexp->dispatch($sqlWalker) . ')';
+ }
+}
\ No newline at end of file
diff --git a/src/Doctrine/Helpers/FieldHelper.php b/src/Doctrine/Helpers/FieldHelper.php
new file mode 100644
index 00000000..11300db3
--- /dev/null
+++ b/src/Doctrine/Helpers/FieldHelper.php
@@ -0,0 +1,124 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Doctrine\Helpers;
+
+use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
+use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
+use Doctrine\ORM\QueryBuilder;
+
+/**
+ * The purpose of this class is to provide help with using the FIELD functions in Doctrine, which depends on the database platform.
+ */
+final class FieldHelper
+{
+ /**
+ * Add an ORDER BY FIELD expression to the query builder. The correct FIELD function is used depending on the database platform.
+ * In this function an already bound paramater is used. If you want to a not already bound value, use the addOrderByFieldValues function.
+ * @param QueryBuilder $qb The query builder to apply the order by to
+ * @param string $field_expr The expression to compare with the values
+ * @param string|int $bound_param The already bound parameter to use for the values
+ * @param string|null $order The order direction (ASC or DESC)
+ * @return QueryBuilder
+ */
+ public static function addOrderByFieldParam(QueryBuilder $qb, string $field_expr, string|int $bound_param, ?string $order = null): QueryBuilder
+ {
+ $db_platform = $qb->getEntityManager()->getConnection()->getDatabasePlatform();
+
+ //If we are on MySQL, we can just use the FIELD function
+ if ($db_platform instanceof AbstractMySQLPlatform ) {
+ $param = (is_numeric($bound_param) ? '?' : ":").(string)$bound_param;
+ $qb->orderBy("FIELD($field_expr, $param)", $order);
+ } else { //Use the sqlite/portable version or postgresql
+ //Retrieve the values from the bound parameter
+ $param = $qb->getParameter($bound_param);
+ if ($param === null) {
+ throw new \InvalidArgumentException("The bound parameter $bound_param does not exist.");
+ }
+
+ //Generate a unique key from the field_expr
+ $key = 'field2_' . (string) $bound_param;
+
+ if ($db_platform instanceof PostgreSQLPlatform) {
+ self::addPostgresOrderBy($qb, $field_expr, $key, $param->getValue(), $order);
+ } else {
+ self::addSqliteOrderBy($qb, $field_expr, $key, $param->getValue(), $order);
+ }
+ }
+
+ return $qb;
+ }
+
+
+ private static function addPostgresOrderBy(QueryBuilder $qb, string $field_expr, string $key, array $values, ?string $order = null): void
+ {
+ //Use postgres native array_position function, to get the index of the value in the array
+ //In the end it gives a similar result as the FIELD function
+ $qb->orderBy("array_position(:$key, $field_expr)", $order);
+
+ //Convert the values to a literal array, to overcome the problem of passing more than 100 parameters
+ $values = array_map(fn($value) => is_string($value) ? "'$value'" : $value, $values);
+ $literalArray = '{' . implode(',', $values) . '}';
+
+ $qb->setParameter($key, $literalArray);
+ }
+
+ private static function addSqliteOrderBy(QueryBuilder $qb, string $field_expr, string $key, array $values, ?string $order = null): void
+ {
+ //Otherwise we emulate it using
+ $qb->orderBy("LOCATE(CONCAT(',', $field_expr, ','), :$key)", $order);
+ //The string must be padded with a comma on both sides, otherwise the search using INSTR will fail
+ $qb->setParameter($key, ',' .implode(',', $values) . ',');
+ }
+
+ /**
+ * Add an ORDER BY FIELD expression to the query builder. The correct FIELD function is used depending on the database platform.
+ * In this function the values are passed as an array. If you want to reuse an existing bound parameter, use the addOrderByFieldParam function.
+ * @param QueryBuilder $qb The query builder to apply the order by to
+ * @param string $field_expr The expression to compare with the values
+ * @param array $values The values to compare with the expression as array
+ * @param string|null $order The order direction (ASC or DESC)
+ * @return QueryBuilder
+ */
+ public static function addOrderByFieldValues(QueryBuilder $qb, string $field_expr, array $values, ?string $order = null): QueryBuilder
+ {
+ $db_platform = $qb->getEntityManager()->getConnection()->getDatabasePlatform();
+
+ $key = 'field2_' . md5($field_expr);
+
+ //If we are on MySQL, we can just use the FIELD function
+ if ($db_platform instanceof AbstractMySQLPlatform) {
+ $qb->orderBy("FIELD2($field_expr, :field_arr)", $order);
+ } elseif ($db_platform instanceof PostgreSQLPlatform) {
+ //Use the postgres native array_position function
+ self::addPostgresOrderBy($qb, $field_expr, $key, $values, $order);
+ } else {
+ //Otherwise use the portable version using string concatenation
+ self::addSqliteOrderBy($qb, $field_expr, $key, $values, $order);
+ }
+
+ $qb->setParameter($key, $values);
+
+ return $qb;
+ }
+}
\ No newline at end of file
diff --git a/src/Doctrine/Middleware/MySQLSSLConnectionMiddlewareDriver.php b/src/Doctrine/Middleware/MySQLSSLConnectionMiddlewareDriver.php
new file mode 100644
index 00000000..2a707e1f
--- /dev/null
+++ b/src/Doctrine/Middleware/MySQLSSLConnectionMiddlewareDriver.php
@@ -0,0 +1,51 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Doctrine\Middleware;
+
+use Composer\CaBundle\CaBundle;
+use Doctrine\DBAL\Driver;
+use Doctrine\DBAL\Driver\Connection;
+use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
+
+/**
+ * This middleware sets SSL options for MySQL connections
+ */
+class MySQLSSLConnectionMiddlewareDriver extends AbstractDriverMiddleware
+{
+ public function __construct(Driver $wrappedDriver, private readonly bool $enabled, private readonly bool $verify = true)
+ {
+ parent::__construct($wrappedDriver);
+ }
+
+ public function connect(array $params): Connection
+ {
+ //Only set this on MySQL connections, as other databases don't support this parameter
+ if($this->enabled && $params['driver'] === 'pdo_mysql') {
+ $params['driverOptions'][\PDO::MYSQL_ATTR_SSL_CA] = CaBundle::getSystemCaRootBundlePath();
+ $params['driverOptions'][\PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = $this->verify;
+ }
+
+ return parent::connect($params);
+ }
+}
\ No newline at end of file
diff --git a/src/Doctrine/Middleware/MySQLSSLConnectionMiddlewareWrapper.php b/src/Doctrine/Middleware/MySQLSSLConnectionMiddlewareWrapper.php
new file mode 100644
index 00000000..8bd25971
--- /dev/null
+++ b/src/Doctrine/Middleware/MySQLSSLConnectionMiddlewareWrapper.php
@@ -0,0 +1,39 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Doctrine\Middleware;
+
+use Doctrine\DBAL\Driver;
+use Doctrine\DBAL\Driver\Middleware;
+
+class MySQLSSLConnectionMiddlewareWrapper implements Middleware
+{
+ public function __construct(private readonly bool $enabled, private readonly bool $verify = true)
+ {
+ }
+
+ public function wrap(Driver $driver): Driver
+ {
+ return new MySQLSSLConnectionMiddlewareDriver($driver, $this->enabled, $this->verify);
+ }
+}
\ No newline at end of file
diff --git a/src/Doctrine/Middleware/SQLiteRegexExtensionMiddlewareDriver.php b/src/Doctrine/Middleware/SQLiteRegexExtensionMiddlewareDriver.php
new file mode 100644
index 00000000..ad572d4c
--- /dev/null
+++ b/src/Doctrine/Middleware/SQLiteRegexExtensionMiddlewareDriver.php
@@ -0,0 +1,117 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Doctrine\Middleware;
+
+use App\Exceptions\InvalidRegexException;
+use Doctrine\DBAL\Driver\Connection;
+use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
+
+/**
+ * This middleware is used to add the regexp operator to the SQLite platform.
+ * As a PHP callback is called for every entry to compare it is most likely much slower than using regex on MySQL.
+ * But as regex is not often used, this should be fine for most use cases, also it is almost impossible to implement a better solution.
+ */
+class SQLiteRegexExtensionMiddlewareDriver extends AbstractDriverMiddleware
+{
+ public function connect(#[\SensitiveParameter] array $params): Connection
+ {
+ //Do connect process first
+ $connection = parent::connect($params); // TODO: Change the autogenerated stub
+
+ //Then add the functions if we are on SQLite
+ if ($params['driver'] === 'pdo_sqlite') {
+ $native_connection = $connection->getNativeConnection();
+
+ //Ensure that the function really exists on the connection, as it is marked as experimental according to PHP documentation
+ if($native_connection instanceof \PDO) {
+ $native_connection->sqliteCreateFunction('REGEXP', self::regexp(...), 2, \PDO::SQLITE_DETERMINISTIC);
+ $native_connection->sqliteCreateFunction('FIELD', self::field(...), -1, \PDO::SQLITE_DETERMINISTIC);
+ $native_connection->sqliteCreateFunction('FIELD2', self::field2(...), 2, \PDO::SQLITE_DETERMINISTIC);
+
+ //Create a new collation for natural sorting
+ $native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...));
+ }
+ }
+
+
+ return $connection;
+ }
+
+ /**
+ * This function emulates the MySQL regexp function for SQLite
+ * @param string $pattern
+ * @param string $value
+ * @return int
+ */
+ final public static function regexp(string $pattern, ?string $value): int
+ {
+ if ($value === null) {
+ return 0;
+ }
+
+ try {
+ return (mb_ereg($pattern, $value)) ? 1 : 0;
+
+ } catch (\ErrorException $e) {
+ throw InvalidRegexException::fromMBRegexError($e);
+ }
+ }
+
+ /**
+ * Very similar to the field function, but takes the array values as a comma separated string.
+ * This is needed as SQLite has a pretty low argument count limit.
+ * @param string|int|null $value
+ * @param string $imploded_array
+ * @return int
+ */
+ final public static function field2(string|int|null $value, string $imploded_array): int
+ {
+ $array = explode(',', $imploded_array);
+ return self::field($value, ...$array);
+ }
+
+ /**
+ * This function emulates the MySQL field function for SQLite
+ * This function returns the index (position) of the first argument in the subsequent arguments.
+ * If the first argument is not found or is NULL, 0 is returned.
+ * @param string|int|null $value
+ * @return int
+ */
+ final public static function field(string|int|null $value, mixed ...$array): int
+ {
+ if ($value === null) {
+ return 0;
+ }
+
+ //We are loose with the types here
+ //@phpstan-ignore-next-line
+ $index = array_search($value, $array, false);
+
+ if ($index === false) {
+ return 0;
+ }
+
+ return $index + 1;
+ }
+}
\ No newline at end of file
diff --git a/src/Doctrine/Middleware/SQLiteRegexExtensionMiddlewareWrapper.php b/src/Doctrine/Middleware/SQLiteRegexExtensionMiddlewareWrapper.php
new file mode 100644
index 00000000..42aafaad
--- /dev/null
+++ b/src/Doctrine/Middleware/SQLiteRegexExtensionMiddlewareWrapper.php
@@ -0,0 +1,35 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Doctrine\Middleware;
+
+use Doctrine\DBAL\Driver;
+use Doctrine\DBAL\Driver\Middleware;
+
+class SQLiteRegexExtensionMiddlewareWrapper implements Middleware
+{
+ public function wrap(Driver $driver): Driver
+ {
+ return new SQLiteRegexExtensionMiddlewareDriver($driver);
+ }
+}
\ No newline at end of file
diff --git a/src/Doctrine/SetSQLMode/SetSQLModeMiddlewareDriver.php b/src/Doctrine/Middleware/SetSQLModeMiddlewareDriver.php
similarity index 79%
rename from src/Doctrine/SetSQLMode/SetSQLModeMiddlewareDriver.php
rename to src/Doctrine/Middleware/SetSQLModeMiddlewareDriver.php
index 4b91ab57..d05b6b9c 100644
--- a/src/Doctrine/SetSQLMode/SetSQLModeMiddlewareDriver.php
+++ b/src/Doctrine/Middleware/SetSQLModeMiddlewareDriver.php
@@ -1,4 +1,7 @@
.
*/
+namespace App\Doctrine\Middleware;
-namespace App\Doctrine\SetSQLMode;
-
+use Doctrine\DBAL\Driver\Connection;
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
-use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
/**
* This command sets the initial command parameter for MySQL connections, so we can set the SQL mode
@@ -29,14 +31,14 @@ use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
*/
class SetSQLModeMiddlewareDriver extends AbstractDriverMiddleware
{
- public function connect(array $params): \Doctrine\DBAL\Driver\Connection
+ public function connect(array $params): Connection
{
//Only set this on MySQL connections, as other databases don't support this parameter
- if($this->getDatabasePlatform() instanceof AbstractMySQLPlatform) {
+ if($params['driver'] === 'pdo_mysql') {
//1002 is \PDO::MYSQL_ATTR_INIT_COMMAND constant value
- $params['driverOptions'][1002] = 'SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode, \'ONLY_FULL_GROUP_BY\', \'\'))';
+ $params['driverOptions'][\PDO::MYSQL_ATTR_INIT_COMMAND] = 'SET SESSION sql_mode=(SELECT REPLACE(@@sql_mode, \'ONLY_FULL_GROUP_BY\', \'\'))';
}
return parent::connect($params);
}
-}
\ No newline at end of file
+}
diff --git a/src/Doctrine/SetSQLMode/SetSQLModeMiddlewareWrapper.php b/src/Doctrine/Middleware/SetSQLModeMiddlewareWrapper.php
similarity index 91%
rename from src/Doctrine/SetSQLMode/SetSQLModeMiddlewareWrapper.php
rename to src/Doctrine/Middleware/SetSQLModeMiddlewareWrapper.php
index 0ff670ba..3307bc7f 100644
--- a/src/Doctrine/SetSQLMode/SetSQLModeMiddlewareWrapper.php
+++ b/src/Doctrine/Middleware/SetSQLModeMiddlewareWrapper.php
@@ -1,4 +1,7 @@
.
*/
-
-namespace App\Doctrine\SetSQLMode;
+namespace App\Doctrine\Middleware;
use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Driver\Middleware;
/**
- * This class wraps the Doctrine DBAL driver and wraps it into an Midleware driver so we can change the SQL mode
+ * This class wraps the Doctrine DBAL driver and wraps it into a Midleware driver, so we can change the SQL mode
*/
class SetSQLModeMiddlewareWrapper implements Middleware
{
-
public function wrap(Driver $driver): Driver
{
return new SetSQLModeMiddlewareDriver($driver);
}
-}
\ No newline at end of file
+}
diff --git a/src/Doctrine/Purger/DoNotUsePurgerFactory.php b/src/Doctrine/Purger/DoNotUsePurgerFactory.php
new file mode 100644
index 00000000..6d487573
--- /dev/null
+++ b/src/Doctrine/Purger/DoNotUsePurgerFactory.php
@@ -0,0 +1,53 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Doctrine\Purger;
+
+use Doctrine\Bundle\FixturesBundle\Purger\PurgerFactory;
+use Doctrine\Common\DataFixtures\Purger\ORMPurgerInterface;
+use Doctrine\Common\DataFixtures\Purger\PurgerInterface;
+use Doctrine\ORM\EntityManagerInterface;
+
+class DoNotUsePurgerFactory implements PurgerFactory
+{
+
+ public function createForEntityManager(
+ ?string $emName,
+ EntityManagerInterface $em,
+ array $excluded = [],
+ bool $purgeWithTruncate = false
+ ): PurgerInterface {
+ return new class() implements ORMPurgerInterface {
+
+ public function purge(): void
+ {
+ throw new \LogicException('Do not use doctrine:fixtures:load directly. Use partdb:fixtures:load instead!');
+ }
+
+ public function setEntityManager(EntityManagerInterface $em): void
+ {
+ // TODO: Implement setEntityManager() method.
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/Doctrine/Purger/ResetAutoIncrementORMPurger.php b/src/Doctrine/Purger/ResetAutoIncrementORMPurger.php
index a38045d4..0fbf6cdb 100644
--- a/src/Doctrine/Purger/ResetAutoIncrementORMPurger.php
+++ b/src/Doctrine/Purger/ResetAutoIncrementORMPurger.php
@@ -27,15 +27,11 @@ use Doctrine\Common\DataFixtures\Purger\PurgerInterface;
use Doctrine\Common\DataFixtures\Sorter\TopologicalSorter;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use Doctrine\DBAL\Platforms\AbstractPlatform;
-use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Identifier;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
-use Doctrine\ORM\Mapping\ClassMetadataInfo;
-
use function array_reverse;
-use function array_search;
use function assert;
use function count;
use function is_callable;
@@ -49,25 +45,15 @@ use function preg_match;
*/
class ResetAutoIncrementORMPurger implements PurgerInterface, ORMPurgerInterface
{
- public const PURGE_MODE_DELETE = 1;
- public const PURGE_MODE_TRUNCATE = 2;
-
- /** @var EntityManagerInterface|null */
- private $em;
+ final public const PURGE_MODE_DELETE = 1;
+ final public const PURGE_MODE_TRUNCATE = 2;
/**
* If the purge should be done through DELETE or TRUNCATE statements
*
* @var int
*/
- private $purgeMode = self::PURGE_MODE_DELETE;
-
- /**
- * Table/view names to be excluded from purge
- *
- * @var string[]
- */
- private $excluded;
+ private int $purgeMode = self::PURGE_MODE_DELETE;
/**
* Construct new purger instance.
@@ -75,18 +61,20 @@ class ResetAutoIncrementORMPurger implements PurgerInterface, ORMPurgerInterface
* @param EntityManagerInterface|null $em EntityManagerInterface instance used for persistence.
* @param string[] $excluded array of table/view names to be excluded from purge
*/
- public function __construct(?EntityManagerInterface $em = null, array $excluded = [])
+ public function __construct(
+ private ?EntityManagerInterface $em = null,
+ /**
+ * Table/view names to be excluded from purge
+ */
+ private readonly array $excluded = []
+ )
{
- $this->em = $em;
- $this->excluded = $excluded;
}
/**
* Set the purge mode
*
- * @param int $mode
*
- * @return void
*/
public function setPurgeMode(int $mode): void
{
@@ -95,8 +83,6 @@ class ResetAutoIncrementORMPurger implements PurgerInterface, ORMPurgerInterface
/**
* Get the purge mode
- *
- * @return int
*/
public function getPurgeMode(): int
{
@@ -125,7 +111,7 @@ class ResetAutoIncrementORMPurger implements PurgerInterface, ORMPurgerInterface
$classes = [];
foreach ($this->em->getMetadataFactory()->getAllMetadata() as $metadata) {
- if ($metadata->isMappedSuperclass || (isset($metadata->isEmbeddedClass) && $metadata->isEmbeddedClass)) {
+ if ($metadata->isMappedSuperclass || ($metadata->isEmbeddedClass !== null && $metadata->isEmbeddedClass)) {
continue;
}
@@ -145,7 +131,7 @@ class ResetAutoIncrementORMPurger implements PurgerInterface, ORMPurgerInterface
$class = $commitOrder[$i];
if (
- (isset($class->isEmbeddedClass) && $class->isEmbeddedClass) ||
+ ($class->isEmbeddedClass !== null && $class->isEmbeddedClass) ||
$class->isMappedSuperclass ||
($class->isInheritanceTypeSingleTable() && $class->name !== $class->rootEntityName)
) {
@@ -174,12 +160,17 @@ class ResetAutoIncrementORMPurger implements PurgerInterface, ORMPurgerInterface
foreach ($orderedTables as $tbl) {
// If we have a filter expression, check it and skip if necessary
- if (! $emptyFilterExpression && ! preg_match($filterExpr, $tbl)) {
+ if (! $emptyFilterExpression && ! preg_match($filterExpr, (string) $tbl)) {
continue;
}
+ // The table name might be quoted, we have to trim it
+ // See https://github.com/Part-DB/Part-DB-server/issues/299
+ $tbl = trim((string) $tbl, '"');
+ $tbl = trim($tbl, '`');
+
// If the table is excluded, skip it as well
- if (array_search($tbl, $this->excluded) !== false) {
+ if (in_array($tbl, $this->excluded, true)) {
continue;
}
@@ -195,8 +186,7 @@ class ResetAutoIncrementORMPurger implements PurgerInterface, ORMPurgerInterface
}
//Reseting autoincrement is only supported on MySQL platforms
- if ($platform instanceof AbstractMySQLPlatform) {
- $connection->beginTransaction();
+ if ($platform instanceof AbstractMySQLPlatform ) { //|| $platform instanceof SqlitePlatform) {
$connection->executeQuery($this->getResetAutoIncrementSQL($tbl, $platform));
}
}
@@ -211,7 +201,16 @@ class ResetAutoIncrementORMPurger implements PurgerInterface, ORMPurgerInterface
{
$tableIdentifier = new Identifier($tableName);
- return 'ALTER TABLE '. $tableIdentifier->getQuotedName($platform) .' AUTO_INCREMENT = 1;';
+ if ($platform instanceof AbstractMySQLPlatform) {
+ return 'ALTER TABLE '.$tableIdentifier->getQuotedName($platform).' AUTO_INCREMENT = 1;';
+ }
+
+ throw new \RuntimeException("Resetting autoincrement is not supported on this platform!");
+
+ //This seems to cause problems somehow
+ /*if ($platform instanceof SqlitePlatform) {
+ return 'DELETE FROM `sqlite_sequence` WHERE name = \''.$tableIdentifier->getQuotedName($platform).'\';';
+ }*/
}
/**
@@ -273,18 +272,13 @@ class ResetAutoIncrementORMPurger implements PurgerInterface, ORMPurgerInterface
return array_reverse($sorter->sort());
}
- /**
- * @param array $classes
- *
- * @return array
- */
private function getAssociationTables(array $classes, AbstractPlatform $platform): array
{
$associationTables = [];
foreach ($classes as $class) {
foreach ($class->associationMappings as $assoc) {
- if (! $assoc['isOwningSide'] || $assoc['type'] !== ClassMetadataInfo::MANY_TO_MANY) {
+ if (! $assoc['isOwningSide'] || $assoc['type'] !== ClassMetadata::MANY_TO_MANY) {
continue;
}
@@ -307,9 +301,6 @@ class ResetAutoIncrementORMPurger implements PurgerInterface, ORMPurgerInterface
return $this->em->getConfiguration()->getQuoteStrategy()->getTableName($class, $platform);
}
- /**
- * @param array $assoc
- */
private function getJoinTableName(
array $assoc,
ClassMetadata $class,
diff --git a/src/Doctrine/Purger/ResetAutoIncrementPurgerFactory.php b/src/Doctrine/Purger/ResetAutoIncrementPurgerFactory.php
index 00152718..4d78e0f0 100644
--- a/src/Doctrine/Purger/ResetAutoIncrementPurgerFactory.php
+++ b/src/Doctrine/Purger/ResetAutoIncrementPurgerFactory.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Doctrine\Purger;
use Doctrine\Bundle\FixturesBundle\Purger\PurgerFactory;
@@ -35,4 +37,4 @@ class ResetAutoIncrementPurgerFactory implements PurgerFactory
return $purger;
}
-}
\ No newline at end of file
+}
diff --git a/src/Doctrine/SQLiteRegexExtension.php b/src/Doctrine/SQLiteRegexExtension.php
deleted file mode 100644
index f1bca465..00000000
--- a/src/Doctrine/SQLiteRegexExtension.php
+++ /dev/null
@@ -1,58 +0,0 @@
-.
- */
-
-namespace App\Doctrine;
-
-use Doctrine\Bundle\DoctrineBundle\EventSubscriber\EventSubscriberInterface;
-use Doctrine\DBAL\Event\ConnectionEventArgs;
-use Doctrine\DBAL\Events;
-use Doctrine\DBAL\Platforms\SqlitePlatform;
-
-/**
- * This subscriber is used to add the regexp operator to the SQLite platform.
- * As a PHP callback is called for every entry to compare it is most likely much slower than using regex on MySQL.
- * But as regex is not often used, this should be fine for most use cases, also it is almost impossible to implement a better solution.
- */
-class SQLiteRegexExtension implements EventSubscriberInterface
-{
- public function postConnect(ConnectionEventArgs $eventArgs): void
- {
- $connection = $eventArgs->getConnection();
-
- //We only execute this on SQLite databases
- if ($connection->getDatabasePlatform() instanceof SqlitePlatform) {
- $native_connection = $connection->getNativeConnection();
-
- //Ensure that the function really exists on the connection, as it is marked as experimental according to PHP documentation
- if($native_connection instanceof \PDO && method_exists($native_connection, 'sqliteCreateFunction' )) {
- $native_connection->sqliteCreateFunction('REGEXP', function ($pattern, $value) {
- return (false !== mb_ereg($pattern, $value)) ? 1 : 0;
- });
- }
- }
- }
-
- public function getSubscribedEvents()
- {
- return[
- Events::postConnect
- ];
- }
-}
\ No newline at end of file
diff --git a/src/Doctrine/Types/ArrayType.php b/src/Doctrine/Types/ArrayType.php
new file mode 100644
index 00000000..daab9b75
--- /dev/null
+++ b/src/Doctrine/Types/ArrayType.php
@@ -0,0 +1,116 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Doctrine\Types;
+
+use Doctrine\DBAL\Platforms\AbstractPlatform;
+use Doctrine\DBAL\Types\Exception\SerializationFailed;
+use Doctrine\DBAL\Types\Type;
+use Doctrine\Deprecations\Deprecation;
+
+use function is_resource;
+use function restore_error_handler;
+use function serialize;
+use function set_error_handler;
+use function stream_get_contents;
+use function unserialize;
+
+use const E_DEPRECATED;
+use const E_USER_DEPRECATED;
+
+/**
+ * This class is taken from doctrine ORM 3.8. https://github.com/doctrine/dbal/blob/3.8.x/src/Types/ArrayType.php
+ *
+ * It was removed in doctrine ORM 4.0. However, we require it for backward compatibility with WebauthnKey.
+ * Therefore, we manually added it here as a custom type as a forward compatibility layer.
+ */
+class ArrayType extends Type
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
+ {
+ return $platform->getClobTypeDeclarationSQL($column);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): string
+ {
+ return serialize($value);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function convertToPHPValue(mixed $value, AbstractPlatform $platform): mixed
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ $value = is_resource($value) ? stream_get_contents($value) : $value;
+
+ set_error_handler(function (int $code, string $message): bool {
+ if ($code === E_DEPRECATED || $code === E_USER_DEPRECATED) {
+ return false;
+ }
+
+ //Change to original code. Use SerializationFailed instead of ConversionException.
+ throw new SerializationFailed("Serialization failed (Code $code): " . $message);
+ });
+
+ try {
+ //Change to original code. Use false for allowed_classes, to avoid unsafe unserialization of objects.
+ return unserialize($value, ['allowed_classes' => false]);
+ } finally {
+ restore_error_handler();
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getName(): string
+ {
+ return "array";
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @deprecated
+ */
+ public function requiresSQLCommentHint(AbstractPlatform $platform): bool
+ {
+ Deprecation::triggerIfCalledFromOutside(
+ 'doctrine/dbal',
+ 'https://github.com/doctrine/dbal/pull/5509',
+ '%s is deprecated.',
+ __METHOD__,
+ );
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/src/Doctrine/Types/BigDecimalType.php b/src/Doctrine/Types/BigDecimalType.php
index f1522857..a9f796dd 100644
--- a/src/Doctrine/Types/BigDecimalType.php
+++ b/src/Doctrine/Types/BigDecimalType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Doctrine\Types;
use Brick\Math\BigDecimal;
@@ -27,24 +29,21 @@ use Doctrine\DBAL\Types\Type;
class BigDecimalType extends Type
{
- public const BIG_DECIMAL = 'big_decimal';
+ final public const BIG_DECIMAL = 'big_decimal';
public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform): string
{
return $platform->getDecimalTypeDeclarationSQL($fieldDeclaration);
}
- /**
- * @param string|null $value
- *
- * @return BigDecimal|BigNumber|mixed
- */
- public function convertToPHPValue($value, AbstractPlatform $platform)
+ public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?BigNumber
{
if (null === $value) {
return null;
}
+
+
return BigDecimal::of($value);
}
@@ -53,7 +52,7 @@ class BigDecimalType extends Type
*/
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
- if (null === $value) {
+ if (!$value instanceof BigDecimal) {
return null;
}
diff --git a/src/Doctrine/Types/TinyIntType.php b/src/Doctrine/Types/TinyIntType.php
new file mode 100644
index 00000000..c2daeeca
--- /dev/null
+++ b/src/Doctrine/Types/TinyIntType.php
@@ -0,0 +1,73 @@
+.
+ */
+namespace App\Doctrine\Types;
+
+use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
+use Doctrine\DBAL\Platforms\AbstractPlatform;
+use Doctrine\DBAL\Platforms\SQLitePlatform;
+use Doctrine\DBAL\Types\Type;
+
+/**
+ * A type to use for tinyint columns in MySQL
+ */
+class TinyIntType extends Type
+{
+
+ public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
+ {
+ //MySQL knows the TINYINT type directly
+ //We do not use the TINYINT for sqlite, as it will be resolved to a BOOL type and bring problems with migrations
+ if ($platform instanceof AbstractMySQLPlatform ) {
+ //Use TINYINT(1) to allow for proper migration diffs
+ return 'TINYINT(1)';
+ }
+
+ //For other platforms, we use the smallest integer type available
+ return $platform->getSmallIntTypeDeclarationSQL($column);
+ }
+
+ public function getName(): string
+ {
+ return 'tinyint';
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @param T $value
+ *
+ * @return (T is null ? null : int)
+ *
+ * @template T
+ */
+ public function convertToPHPValue($value, AbstractPlatform $platform): ?int
+ {
+ return $value === null ? null : (int) $value;
+ }
+
+ public function requiresSQLCommentHint(AbstractPlatform $platform): bool
+ {
+ //We use the comment, so that doctrine migrations can properly detect, that nothing has changed and no migration is needed.
+ return true;
+ }
+}
diff --git a/src/Doctrine/Types/UTCDateTimeImmutableType.php b/src/Doctrine/Types/UTCDateTimeImmutableType.php
new file mode 100644
index 00000000..c0d6659d
--- /dev/null
+++ b/src/Doctrine/Types/UTCDateTimeImmutableType.php
@@ -0,0 +1,97 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Doctrine\Types;
+
+use DateTime;
+use DateTimeInterface;
+use DateTimeZone;
+use Doctrine\DBAL\Platforms\AbstractPlatform;
+use Doctrine\DBAL\Types\ConversionException;
+use Doctrine\DBAL\Types\DateTimeImmutableType;
+use Doctrine\DBAL\Types\DateTimeType;
+use Doctrine\DBAL\Types\Exception\InvalidFormat;
+
+/**
+ * This DateTimeImmutableType all dates to UTC, so it can be later used with the timezones.
+ * Taken (and adapted) from here: https://www.doctrine-project.org/projects/doctrine-orm/en/2.6/cookbook/working-with-datetime.html.
+ */
+class UTCDateTimeImmutableType extends DateTimeImmutableType
+{
+ private static ?DateTimeZone $utc_timezone = null;
+
+ /**
+ * {@inheritdoc}
+ *
+ * @param T $value
+ *
+ * @return (T is null ? null : string)
+ *
+ * @template T
+ */
+ public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
+ {
+ if (!self::$utc_timezone instanceof \DateTimeZone) {
+ self::$utc_timezone = new DateTimeZone('UTC');
+ }
+
+ if ($value instanceof \DateTimeImmutable) {
+ $value = $value->setTimezone(self::$utc_timezone);
+ }
+
+ return parent::convertToDatabaseValue($value, $platform);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @param T $value
+ *
+ * @template T
+ */
+ public function convertToPHPValue($value, AbstractPlatform $platform): ?\DateTimeImmutable
+ {
+ if (!self::$utc_timezone instanceof \DateTimeZone) {
+ self::$utc_timezone = new DateTimeZone('UTC');
+ }
+
+ if (null === $value || $value instanceof \DateTimeImmutable) {
+ return $value;
+ }
+
+ $converted = \DateTimeImmutable::createFromFormat(
+ $platform->getDateTimeFormatString(),
+ $value,
+ self::$utc_timezone
+ );
+
+ if (!$converted) {
+ throw InvalidFormat::new(
+ $value,
+ static::class,
+ $platform->getDateTimeFormatString(),
+ );
+ }
+
+ return $converted;
+ }
+}
diff --git a/src/Doctrine/Types/UTCDateTimeType.php b/src/Doctrine/Types/UTCDateTimeType.php
index c9fe215b..a6fda747 100644
--- a/src/Doctrine/Types/UTCDateTimeType.php
+++ b/src/Doctrine/Types/UTCDateTimeType.php
@@ -23,10 +23,12 @@ declare(strict_types=1);
namespace App\Doctrine\Types;
use DateTime;
+use DateTimeInterface;
use DateTimeZone;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\DateTimeType;
+use Doctrine\DBAL\Types\Exception\InvalidFormat;
/**
* This DateTimeType all dates to UTC, so it can be later used with the timezones.
@@ -47,7 +49,7 @@ class UTCDateTimeType extends DateTimeType
*/
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
- if (!self::$utc_timezone) {
+ if (!self::$utc_timezone instanceof \DateTimeZone) {
self::$utc_timezone = new DateTimeZone('UTC');
}
@@ -58,9 +60,16 @@ class UTCDateTimeType extends DateTimeType
return parent::convertToDatabaseValue($value, $platform);
}
+ /**
+ * {@inheritDoc}
+ *
+ * @param T $value
+ *
+ * @template T
+ */
public function convertToPHPValue($value, AbstractPlatform $platform): ?DateTime
{
- if (!self::$utc_timezone) {
+ if (!self::$utc_timezone instanceof \DateTimeZone) {
self::$utc_timezone = new DateTimeZone('UTC');
}
@@ -75,7 +84,11 @@ class UTCDateTimeType extends DateTimeType
);
if (!$converted) {
- throw ConversionException::conversionFailedFormat($value, $this->getName(), $platform->getDateTimeFormatString());
+ throw InvalidFormat::new(
+ $value,
+ static::class,
+ $platform->getDateTimeFormatString(),
+ );
}
return $converted;
diff --git a/src/Entity/Attachments/Attachment.php b/src/Entity/Attachments/Attachment.php
index a4807a21..00cf581a 100644
--- a/src/Entity/Attachments/Attachment.php
+++ b/src/Entity/Attachments/Attachment.php
@@ -22,103 +22,191 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
+use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
+use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
+use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use App\ApiPlatform\DocumentedAPIProperties\DocumentedAPIProperty;
+use App\ApiPlatform\Filter\EntityFilter;
+use App\ApiPlatform\Filter\LikeFilter;
+use App\ApiPlatform\HandleAttachmentsUploadsProcessor;
use App\Entity\Base\AbstractNamedDBElement;
+use App\EntityListeners\AttachmentDeleteListener;
+use App\Repository\AttachmentRepository;
use App\Validator\Constraints\Selectable;
+use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
-use Symfony\Component\Validator\Constraints as Assert;
-use function in_array;
use InvalidArgumentException;
use LogicException;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Serializer\Annotation\SerializedName;
+use Symfony\Component\Serializer\Attribute\DiscriminatorMap;
+use Symfony\Component\Validator\Constraints as Assert;
+
+use function in_array;
/**
* Class Attachment.
- *
- * @ORM\Entity(repositoryClass="App\Repository\AttachmentRepository")
- * @ORM\Table(name="`attachments`", indexes={
- * @ORM\Index(name="attachments_idx_id_element_id_class_name", columns={"id", "element_id", "class_name"}),
- * @ORM\Index(name="attachments_idx_class_name_id", columns={"class_name", "id"}),
- * @ORM\Index(name="attachment_name_idx", columns={"name"}),
- * @ORM\Index(name="attachment_element_idx", columns={"class_name", "element_id"})
- * })
- * @ORM\InheritanceType("SINGLE_TABLE")
- * @ORM\DiscriminatorColumn(name="class_name", type="string")
- * @ORM\DiscriminatorMap({
- * "PartDB\Part" = "PartAttachment", "Part" = "PartAttachment",
- * "PartDB\Device" = "ProjectAttachment", "Device" = "ProjectAttachment",
- * "AttachmentType" = "AttachmentTypeAttachment", "Category" = "CategoryAttachment",
- * "Footprint" = "FootprintAttachment", "Manufacturer" = "ManufacturerAttachment",
- * "Currency" = "CurrencyAttachment", "Group" = "GroupAttachment",
- * "MeasurementUnit" = "MeasurementUnitAttachment", "Storelocation" = "StorelocationAttachment",
- * "Supplier" = "SupplierAttachment", "User" = "UserAttachment", "LabelProfile" = "LabelAttachment",
- * })
- * @ORM\EntityListeners({"App\EntityListeners\AttachmentDeleteListener"})
+ * @see \App\Tests\Entity\Attachments\AttachmentTest
+ * @template-covariant T of AttachmentContainingDBElement
*/
+#[ORM\Entity(repositoryClass: AttachmentRepository::class)]
+#[ORM\InheritanceType('SINGLE_TABLE')]
+#[ORM\DiscriminatorColumn(name: 'class_name', type: 'string')]
+#[ORM\DiscriminatorMap(self::ORM_DISCRIMINATOR_MAP)]
+#[ORM\EntityListeners([AttachmentDeleteListener::class])]
+#[ORM\Table(name: '`attachments`')]
+#[ORM\Index(columns: ['id', 'element_id', 'class_name'], name: 'attachments_idx_id_element_id_class_name')]
+#[ORM\Index(columns: ['class_name', 'id'], name: 'attachments_idx_class_name_id')]
+#[ORM\Index(columns: ['name'], name: 'attachment_name_idx')]
+#[ORM\Index(columns: ['class_name', 'element_id'], name: 'attachment_element_idx')]
+#[ApiResource(
+ operations: [
+ new Get(security: 'is_granted("read", object)'),
+ new GetCollection(security: 'is_granted("@attachments.list_attachments")'),
+ new Post(securityPostDenormalize: 'is_granted("create", object)', ),
+ new Patch(security: 'is_granted("edit", object)'),
+ new Delete(security: 'is_granted("delete", object)'),
+ ],
+ normalizationContext: ['groups' => ['attachment:read', 'attachment:read:standalone', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
+ denormalizationContext: ['groups' => ['attachment:write', 'attachment:write:standalone', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
+ processor: HandleAttachmentsUploadsProcessor::class,
+)]
+//This property is added by the denormalizer in order to resolve the placeholder
+#[DocumentedAPIProperty(
+ schemaName: 'Attachment-Read', property: 'internal_path', type: 'string', nullable: false,
+ description: 'The URL to the internally saved copy of the file, if one exists',
+ example: '/media/part/2/bc547-6508afa5a79c8.pdf'
+)]
+#[DocumentedAPIProperty(
+ schemaName: 'Attachment-Read', property: 'thumbnail_url', type: 'string', nullable: true,
+ description: 'The URL to a thumbnail version of this file. This only exists for internal picture attachments.'
+)]
+#[ApiFilter(LikeFilter::class, properties: ["name"])]
+#[ApiFilter(EntityFilter::class, properties: ["attachment_type"])]
+#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
+#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
+//This discriminator map is required for API platform to know which class to use for deserialization, when creating a new attachment.
+#[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)]
abstract class Attachment extends AbstractNamedDBElement
{
+ private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'Device' => ProjectAttachment::class,
+ 'AttachmentType' => AttachmentTypeAttachment::class,
+ 'Category' => CategoryAttachment::class, 'Footprint' => FootprintAttachment::class, 'Manufacturer' => ManufacturerAttachment::class,
+ 'Currency' => CurrencyAttachment::class, 'Group' => GroupAttachment::class, 'MeasurementUnit' => MeasurementUnitAttachment::class,
+ 'Storelocation' => StorageLocationAttachment::class, 'Supplier' => SupplierAttachment::class,
+ 'User' => UserAttachment::class, 'LabelProfile' => LabelAttachment::class];
+
+ /*
+ * The discriminator map used for API platform. The key should be the same as the api platform short type (the @type JSONLD field).
+ */
+ private const API_DISCRIMINATOR_MAP = ["Part" => PartAttachment::class, "Project" => ProjectAttachment::class, "AttachmentType" => AttachmentTypeAttachment::class,
+ "Category" => CategoryAttachment::class, "Footprint" => FootprintAttachment::class, "Manufacturer" => ManufacturerAttachment::class,
+ "Currency" => CurrencyAttachment::class, "Group" => GroupAttachment::class, "MeasurementUnit" => MeasurementUnitAttachment::class,
+ "StorageLocation" => StorageLocationAttachment::class, "Supplier" => SupplierAttachment::class, "User" => UserAttachment::class, "LabelProfile" => LabelAttachment::class];
+
/**
* A list of file extensions, that browsers can show directly as image.
* Based on: https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types
- * It will be used to determine if a attachment is a picture and therefore will be shown to user as preview.
+ * It will be used to determine if an attachment is a picture and therefore will be shown to user as preview.
*/
- public const PICTURE_EXTS = ['apng', 'bmp', 'gif', 'ico', 'cur', 'jpg', 'jpeg', 'jfif', 'pjpeg', 'pjp', 'png',
+ final public const PICTURE_EXTS = ['apng', 'bmp', 'gif', 'ico', 'cur', 'jpg', 'jpeg', 'jfif', 'pjpeg', 'pjp', 'png',
'svg', 'webp', ];
/**
* A list of extensions that will be treated as a 3D Model that can be shown to user directly in Part-DB.
*/
- public const MODEL_EXTS = ['x3d'];
+ final public const MODEL_EXTS = ['x3d'];
- /**
- * When the path begins with one of this placeholders.
- */
- public const INTERNAL_PLACEHOLDER = ['%BASE%', '%MEDIA%', '%SECURE%'];
/**
* @var array placeholders for attachments which using built in files
*/
- public const BUILTIN_PLACEHOLDER = ['%FOOTPRINTS%', '%FOOTPRINTS3D%'];
+ final public const BUILTIN_PLACEHOLDER = ['%FOOTPRINTS%', '%FOOTPRINTS3D%'];
/**
* @var string The class of the element that can be passed to this attachment. Must be overridden in subclasses.
+ * @phpstan-var class-string
*/
- public const ALLOWED_ELEMENT_CLASS = '';
+ protected const ALLOWED_ELEMENT_CLASS = AttachmentContainingDBElement::class;
+
+ /**
+ * @var AttachmentUpload|null The options used for uploading a file to this attachment or modify it.
+ * This value is not persisted in the database, but is just used to pass options to the upload manager.
+ * If it is null, no upload process is started.
+ */
+ #[Groups(['attachment:write'])]
+ protected ?AttachmentUpload $upload = null;
/**
* @var string|null the original filename the file had, when the user uploaded it
- * @ORM\Column(type="string", nullable=true)
*/
+ #[ORM\Column(type: Types::STRING, nullable: true)]
+ #[Groups(['attachment:read', 'import'])]
+ #[Assert\Length(max: 255)]
protected ?string $original_filename = null;
/**
- * @var string The path to the file relative to a placeholder path like %MEDIA%
- * @ORM\Column(type="string", name="path")
+ * @var string|null If a copy of the file is stored internally, the path to the file relative to a placeholder
+ * path like %MEDIA%
*/
- protected string $path = '';
+ #[ORM\Column(type: Types::STRING, nullable: true)]
+ protected ?string $internal_path = null;
+
/**
- * ORM mapping is done in sub classes (like PartAttachment).
+ * @var string|null The path to the external source if the file is stored externally or was downloaded from an
+ * external source. Null if there is no external source.
*/
+ #[ORM\Column(type: Types::STRING, nullable: true)]
+ #[Groups(['attachment:read'])]
+ #[ApiProperty(example: 'http://example.com/image.jpg')]
+ protected ?string $external_path = null;
+
+ /**
+ * @var string the name of this element
+ */
+ #[Assert\NotBlank(message: 'validator.attachment.name_not_blank')]
+ #[Groups(['simple', 'extended', 'full', 'attachment:read', 'attachment:write', 'import'])]
+ protected string $name = '';
+
+ /**
+ * ORM mapping is done in subclasses (like PartAttachment).
+ * @phpstan-param T|null $element
+ */
+ #[Groups(['attachment:read:standalone', 'attachment:write:standalone'])]
+ #[ApiProperty(writableLink: false)]
protected ?AttachmentContainingDBElement $element = null;
- /**
- * @var bool
- * @ORM\Column(type="boolean")
- */
+ #[ORM\Column(type: Types::BOOLEAN)]
+ #[Groups(['attachment:read', 'attachment_write', 'full', 'import'])]
protected bool $show_in_table = false;
- /**
- * @var AttachmentType
- * @ORM\ManyToOne(targetEntity="AttachmentType", inversedBy="attachments_with_type")
- * @ORM\JoinColumn(name="type_id", referencedColumnName="id", nullable=false)
- * @Selectable()
- * @Assert\NotNull(message="validator.attachment.must_not_be_null")
- */
+ #[Assert\NotNull(message: 'validator.attachment.must_not_be_null')]
+ #[ORM\ManyToOne(targetEntity: AttachmentType::class, inversedBy: 'attachments_with_type')]
+ #[ORM\JoinColumn(name: 'type_id', nullable: false)]
+ #[Selectable]
+ #[Groups(['attachment:read', 'attachment:write', 'import', 'full'])]
+ #[ApiProperty(readableLink: false)]
protected ?AttachmentType $attachment_type = null;
+ #[Groups(['attachment:read'])]
+ protected ?\DateTimeImmutable $addedDate = null;
+ #[Groups(['attachment:read'])]
+ protected ?\DateTimeImmutable $lastModified = null;
+
+
public function __construct()
{
//parent::__construct();
- if ('' === static::ALLOWED_ELEMENT_CLASS) {
+ if (AttachmentContainingDBElement::class === static::ALLOWED_ELEMENT_CLASS) {
throw new LogicException('An *Attachment class must override the ALLOWED_ELEMENT_CLASS const!');
}
}
@@ -131,61 +219,106 @@ abstract class Attachment extends AbstractNamedDBElement
}
}
+ /**
+ * Gets the upload currently associated with this attachment.
+ * This is only temporary and not persisted directly in the database.
+ * @internal This function should only be used by the Attachment Submit handler service
+ * @return AttachmentUpload|null
+ */
+ public function getUpload(): ?AttachmentUpload
+ {
+ return $this->upload;
+ }
+
+ /**
+ * Sets the current upload for this attachment.
+ * It will be processed as the attachment is persisted/flushed.
+ * @param AttachmentUpload|null $upload
+ * @return $this
+ */
+ public function setUpload(?AttachmentUpload $upload): Attachment
+ {
+ $this->upload = $upload;
+ return $this;
+ }
+
+
+
/***********************************************************
* Various function
***********************************************************/
/**
* Check if this attachment is a picture (analyse the file's extension).
- * If the link is external, it is assumed that this is true.
+ * If the link is only external and doesn't contain an extension, it is assumed that this is true.
*
* @return bool * true if the file extension is a picture extension
* * otherwise false
*/
+ #[Groups(['attachment:read'])]
public function isPicture(): bool
{
- //We can not check if a external link is a picture, so just assume this is false
- if ($this->isExternal()) {
- return true;
+ if($this->hasInternal()){
+
+ $extension = pathinfo($this->getInternalPath(), PATHINFO_EXTENSION);
+
+ return in_array(strtolower($extension), static::PICTURE_EXTS, true);
+
}
+ if ($this->hasExternal()) {
+ //Check if we can extract a file extension from the URL
+ $extension = pathinfo(parse_url($this->getExternalPath(), PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
- $extension = pathinfo($this->getPath(), PATHINFO_EXTENSION);
-
- return in_array(strtolower($extension), static::PICTURE_EXTS, true);
+ //If no extension is found or it is known picture extension, we assume that this is a picture extension
+ return $extension === '' || in_array(strtolower($extension), static::PICTURE_EXTS, true);
+ }
+ //File doesn't have an internal, nor an external copy. This shouldn't happen, but it certainly isn't a picture...
+ return false;
}
/**
* Check if this attachment is a 3D model and therefore can be directly shown to user.
- * If the attachment is external, false is returned (3D Models must be internal).
+ * If no internal copy exists, false is returned (3D Models must be internal).
*/
+ #[Groups(['attachment:read'])]
+ #[SerializedName('3d_model')]
public function is3DModel(): bool
{
//We just assume that 3D Models are internally saved, otherwise we get problems loading them.
- if ($this->isExternal()) {
+ if (!$this->hasInternal()) {
return false;
}
- $extension = pathinfo($this->getPath(), PATHINFO_EXTENSION);
+ $extension = pathinfo($this->getInternalPath(), PATHINFO_EXTENSION);
return in_array(strtolower($extension), static::MODEL_EXTS, true);
}
/**
- * Checks if the attachment file is externally saved (the database saves an URL).
+ * Checks if this attachment has a path to an external file
*
- * @return bool true, if the file is saved externally
+ * @return bool true, if there is a path to an external file
+ * @phpstan-assert-if-true non-empty-string $this->external_path
+ * @phpstan-assert-if-true non-empty-string $this->getExternalPath())
*/
- public function isExternal(): bool
+ #[Groups(['attachment:read'])]
+ public function hasExternal(): bool
{
- //When path is empty, this attachment can not be external
- if (empty($this->path)) {
- return false;
- }
+ return $this->external_path !== null && $this->external_path !== '';
+ }
- //After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode
- $tmp = explode('/', $this->path);
-
- return !in_array($tmp[0], array_merge(static::INTERNAL_PLACEHOLDER, static::BUILTIN_PLACEHOLDER), true);
+ /**
+ * Checks if this attachment has a path to an internal file.
+ * Does not check if the file exists.
+ *
+ * @return bool true, if there is a path to an internal file
+ * @phpstan-assert-if-true non-empty-string $this->internal_path
+ * @phpstan-assert-if-true non-empty-string $this->getInternalPath())
+ */
+ #[Groups(['attachment:read'])]
+ public function hasInternal(): bool
+ {
+ return $this->internal_path !== null && $this->internal_path !== '';
}
/**
@@ -194,10 +327,16 @@ abstract class Attachment extends AbstractNamedDBElement
*
* @return bool true, if the file is secure
*/
+ #[Groups(['attachment:read'])]
+ #[SerializedName('private')]
public function isSecure(): bool
{
+ if ($this->internal_path === null) {
+ return false;
+ }
+
//After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode
- $tmp = explode('/', $this->path);
+ $tmp = explode('/', $this->internal_path);
return '%SECURE%' === $tmp[0];
}
@@ -206,11 +345,16 @@ abstract class Attachment extends AbstractNamedDBElement
* Checks if the attachment file is using a builtin file. (see BUILTIN_PLACEHOLDERS const for possible placeholders)
* If a file is built in, the path is shown to user in url field (no sensitive infos are provided).
*
- * @return bool true if the attachment is using an builtin file
+ * @return bool true if the attachment is using a builtin file
*/
+ #[Groups(['attachment:read'])]
public function isBuiltIn(): bool
{
- return static::checkIfBuiltin($this->path);
+ if ($this->internal_path === null) {
+ return false;
+ }
+
+ return static::checkIfBuiltin($this->internal_path);
}
/********************************************************************************
@@ -222,27 +366,28 @@ abstract class Attachment extends AbstractNamedDBElement
/**
* Returns the extension of the file referenced via the attachment.
* For a path like %BASE/path/foo.bar, bar will be returned.
- * If this attachment is external null is returned.
+ * If this attachment is only external null is returned.
*
* @return string|null the file extension in lower case
*/
public function getExtension(): ?string
{
- if ($this->isExternal()) {
+ if (!$this->hasInternal()) {
return null;
}
- if (!empty($this->original_filename)) {
+ if ($this->original_filename !== null && $this->original_filename !== '') {
return strtolower(pathinfo($this->original_filename, PATHINFO_EXTENSION));
}
- return strtolower(pathinfo($this->getPath(), PATHINFO_EXTENSION));
+ return strtolower(pathinfo($this->getInternalPath(), PATHINFO_EXTENSION));
}
/**
* Get the element, associated with this Attachment (for example a "Part" object).
*
- * @return AttachmentContainingDBElement the associated Element
+ * @return AttachmentContainingDBElement|null the associated Element
+ * @phpstan-return T|null
*/
public function getElement(): ?AttachmentContainingDBElement
{
@@ -250,59 +395,63 @@ abstract class Attachment extends AbstractNamedDBElement
}
/**
- * The URL to the external file, or the path to the built in file.
+ * The URL to the external file, or the path to the built-in file, but not paths to uploaded files.
* Returns null, if the file is not external (and not builtin).
+ * The output of this function is such, that no changes occur when it is fed back into setURL().
+ * Required for the Attachment form field.
*/
public function getURL(): ?string
{
- if (!$this->isExternal() && !$this->isBuiltIn()) {
- return null;
+ if($this->hasExternal()){
+ return $this->getExternalPath();
}
-
- return $this->path;
+ if($this->isBuiltIn()){
+ return $this->getInternalPath();
+ }
+ return null;
}
/**
* Returns the hostname where the external file is stored.
- * Returns null, if the file is not external.
+ * Returns null, if there is no external path.
*/
public function getHost(): ?string
{
- if (!$this->isExternal()) {
+ if (!$this->hasExternal()) {
return null;
}
- return parse_url($this->getURL(), PHP_URL_HOST);
+ return parse_url($this->getExternalPath(), PHP_URL_HOST);
}
- /**
- * Get the filepath, relative to %BASE%.
- *
- * @return string A string like %BASE/path/foo.bar
- */
- public function getPath(): string
+ public function getInternalPath(): ?string
{
- return $this->path;
+ return $this->internal_path;
+ }
+
+ public function getExternalPath(): ?string
+ {
+ return $this->external_path;
}
/**
* Returns the filename of the attachment.
* For a path like %BASE/path/foo.bar, foo.bar will be returned.
*
- * If the path is a URL (can be checked via isExternal()), null will be returned.
+ * If there is no internal copy of the file, null will be returned.
*/
public function getFilename(): ?string
{
- if ($this->isExternal()) {
+ if (!$this->hasInternal()) {
return null;
}
//If we have a stored original filename, then use it
- if (!empty($this->original_filename)) {
+ if ($this->original_filename !== null && $this->original_filename !== '') {
return $this->original_filename;
}
- return pathinfo($this->getPath(), PATHINFO_BASENAME);
+ return pathinfo($this->getInternalPath(), PATHINFO_BASENAME);
}
/**
@@ -360,12 +509,12 @@ abstract class Attachment extends AbstractNamedDBElement
/**
* Sets the element that is associated with this attachment.
- *
* @return $this
*/
public function setElement(AttachmentContainingDBElement $element): self
{
- if (!is_a($element, static::ALLOWED_ELEMENT_CLASS)) {
+ //Do not allow Rector to replace this check with a instanceof. It will not work!!
+ if (!is_a($element, static::ALLOWED_ELEMENT_CLASS, true)) {
throw new InvalidArgumentException(sprintf('The element associated with a %s must be a %s!', static::class, static::ALLOWED_ELEMENT_CLASS));
}
@@ -375,15 +524,12 @@ abstract class Attachment extends AbstractNamedDBElement
}
/**
- * Sets the filepath (with relative placeholder) for this attachment.
- *
- * @param string $path the new filepath of the attachment
- *
- * @return Attachment
+ * Sets the path to a file hosted internally. If you set this path to a file that was not downloaded from the
+ * external source in external_path, make sure to reset external_path.
*/
- public function setPath(string $path): self
+ public function setInternalPath(?string $internal_path): self
{
- $this->path = $path;
+ $this->internal_path = $internal_path;
return $this;
}
@@ -399,24 +545,61 @@ abstract class Attachment extends AbstractNamedDBElement
}
/**
- * Sets the url associated with this attachment.
- * If the url is empty nothing is changed, to not override the file path.
- *
- * @return Attachment
+ * Sets up the paths using a user provided string which might contain an external path or a builtin path. Allows
+ * resetting the external path if an internal path exists. Resets any other paths if a (nonempty) new path is set.
*/
+ #[Groups(['attachment:write'])]
+ #[SerializedName('url')]
+ #[ApiProperty(description: 'Set the path of the attachment here.
+ Provide either an external URL, a path to a builtin file (like %FOOTPRINTS%/Active/ICs/IC_DFS.png) or an empty
+ string if the attachment has an internal file associated and you\'d like to reset the external source.
+ If you set a new (nonempty) file path any associated internal file will be removed!')]
public function setURL(?string $url): self
{
- //Only set if the URL is not empty
- if (!empty($url)) {
- if (false !== strpos($url, '%BASE%') || false !== strpos($url, '%MEDIA%')) {
- throw new InvalidArgumentException('You can not reference internal files via the url field! But nice try!');
- }
-
- $this->path = $url;
- //Reset internal filename
- $this->original_filename = null;
+ //Don't allow the user to set an empty external path if the internal path is empty already
+ if (($url === null || $url === "") && !$this->hasInternal()) {
+ return $this;
}
+ //The URL field can also contain the special builtin internal paths, so we need to distinguish here
+ if ($this::checkIfBuiltin($url)) {
+ $this->setInternalPath($url);
+ //make sure the external path isn't still pointing to something unrelated
+ $this->setExternalPath(null);
+ } else {
+ $this->setExternalPath($url);
+ }
+ return $this;
+ }
+
+
+ /**
+ * Sets the path to a file hosted on an external server. Setting the external path to a (nonempty) value different
+ * from the the old one _clears_ the internal path, so that the external path reflects where any associated internal
+ * file came from.
+ */
+ public function setExternalPath(?string $external_path): self
+ {
+ //If we only clear the external path, don't reset the internal path, since that could be confusing
+ if($external_path === null || $external_path === '') {
+ $this->external_path = null;
+ return $this;
+ }
+
+ $external_path = trim($external_path);
+ //Escape spaces in URL
+ $external_path = str_replace(' ', '%20', $external_path);
+
+ if($this->external_path === $external_path) {
+ //Nothing changed, nothing to do
+ return $this;
+ }
+
+ $this->external_path = $external_path;
+ $this->internal_path = null;
+ //Reset internal filename
+ $this->original_filename = null;
+
return $this;
}
@@ -427,17 +610,22 @@ abstract class Attachment extends AbstractNamedDBElement
/**
* Checks if the given path is a path to a builtin resource.
*
- * @param string $path The path that should be checked
+ * @param string|null $path The path that should be checked
*
* @return bool true if the path is pointing to a builtin resource
*/
- public static function checkIfBuiltin(string $path): bool
+ public static function checkIfBuiltin(?string $path): bool
{
+ //An empty path can't be a builtin
+ if ($path === null) {
+ return false;
+ }
+
//After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode
$tmp = explode('/', $path);
//Builtins must have a %PLACEHOLDER% construction
- return in_array($tmp[0], static::BUILTIN_PLACEHOLDER, false);
+ return in_array($tmp[0], static::BUILTIN_PLACEHOLDER, true);
}
/**
@@ -446,9 +634,9 @@ abstract class Attachment extends AbstractNamedDBElement
* @param string $string The string which should be checked
* @param bool $path_required If true, the string must contain a path to be valid. (e.g. foo.bar would be invalid, foo.bar/test.php would be valid).
* @param bool $only_http Set this to true, if only HTTPS or HTTP schemata should be allowed.
- * *Caution: When this is set to false, a attacker could use the file:// schema, to get internal server files, like /etc/passwd.*
+ * *Caution: When this is set to false, an attacker could use the file:// schema, to get internal server files, like /etc/passwd.*
*
- * @return bool True if the string is a valid URL. False, if the string is not an URL or invalid.
+ * @return bool True if the string is a valid URL. False, if the string is not a URL or invalid.
*/
public static function isValidURL(string $string, bool $path_required = true, bool $only_http = true): bool
{
@@ -464,4 +652,13 @@ abstract class Attachment extends AbstractNamedDBElement
return (bool) filter_var($string, FILTER_VALIDATE_URL);
}
+
+ /**
+ * Returns the class of the element that is allowed to be associated with this attachment.
+ * @return string
+ */
+ public function getElementClass(): string
+ {
+ return static::ALLOWED_ELEMENT_CLASS;
+ }
}
diff --git a/src/Entity/Attachments/AttachmentContainingDBElement.php b/src/Entity/Attachments/AttachmentContainingDBElement.php
index 02aabadc..a78cb1f4 100644
--- a/src/Entity/Attachments/AttachmentContainingDBElement.php
+++ b/src/Entity/Attachments/AttachmentContainingDBElement.php
@@ -26,25 +26,27 @@ use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\MasterAttachmentTrait;
use App\Entity\Contracts\HasAttachmentsInterface;
use App\Entity\Contracts\HasMasterAttachmentInterface;
+use App\Repository\AttachmentContainingDBElementRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Serializer\Annotation\Groups;
/**
- * @ORM\MappedSuperclass()
+ * @template AT of Attachment
*/
+#[ORM\MappedSuperclass(repositoryClass: AttachmentContainingDBElementRepository::class)]
abstract class AttachmentContainingDBElement extends AbstractNamedDBElement implements HasMasterAttachmentInterface, HasAttachmentsInterface
{
use MasterAttachmentTrait;
/**
- * @var Attachment[]|Collection
- * //TODO
- * //@ORM\OneToMany(targetEntity="Attachment", mappedBy="element")
- *
- * Mapping is done in sub classes like part
+ * @var Collection
+ * @phpstan-var Collection
+ * ORM Mapping is done in subclasses (e.g. Part)
*/
- protected $attachments;
+ #[Groups(['full', 'import'])]
+ protected Collection $attachments;
public function __construct()
{
@@ -77,9 +79,7 @@ abstract class AttachmentContainingDBElement extends AbstractNamedDBElement impl
*********************************************************************************/
/**
- * Gets all attachments associated with this element.
- *
- * @return Attachment[]|Collection
+ * Gets all attachments associated with this element.
*/
public function getAttachments(): Collection
{
diff --git a/src/Entity/Attachments/AttachmentType.php b/src/Entity/Attachments/AttachmentType.php
index ad7a0e61..22333c16 100644
--- a/src/Entity/Attachments/AttachmentType.php
+++ b/src/Entity/Attachments/AttachmentType.php
@@ -22,66 +22,128 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
+use Doctrine\Common\Collections\Criteria;
+use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
+use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
+use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Link;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\OpenApi\Model\Operation;
+use ApiPlatform\Serializer\Filter\PropertyFilter;
+use App\ApiPlatform\Filter\LikeFilter;
+use App\Repository\StructuralDBElementRepository;
+use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parameters\AttachmentTypeParameter;
use App\Validator\Constraints\ValidFileFilter;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Class AttachmentType.
- *
- * @ORM\Entity(repositoryClass="App\Repository\StructuralDBElementRepository")
- * @ORM\Table(name="`attachment_types`", indexes={
- * @ORM\Index(name="attachment_types_idx_name", columns={"name"}),
- * @ORM\Index(name="attachment_types_idx_parent_name", columns={"parent_id", "name"}),
- * })
+ * @see \App\Tests\Entity\Attachments\AttachmentTypeTest
+ * @extends AbstractStructuralDBElement
*/
+#[ORM\Entity(repositoryClass: StructuralDBElementRepository::class)]
+#[ORM\Table(name: '`attachment_types`')]
+#[ORM\Index(columns: ['name'], name: 'attachment_types_idx_name')]
+#[ORM\Index(columns: ['parent_id', 'name'], name: 'attachment_types_idx_parent_name')]
+#[ApiResource(
+ operations: [
+ new Get(security: 'is_granted("read", object)'),
+ new GetCollection(security: 'is_granted("@attachment_types.read")'),
+ new Post(securityPostDenormalize: 'is_granted("create", object)'),
+ new Patch(security: 'is_granted("edit", object)'),
+ new Delete(security: 'is_granted("delete", object)'),
+ ],
+ normalizationContext: ['groups' => ['attachment_type:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
+ denormalizationContext: ['groups' => ['attachment_type:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
+)]
+#[ApiResource(
+ uriTemplate: '/attachment_types/{id}/children.{_format}',
+ operations: [
+ new GetCollection(openapi: new Operation(summary: 'Retrieves the children elements of an attachment type.'),
+ security: 'is_granted("@attachment_types.read")')
+ ],
+ uriVariables: [
+ 'id' => new Link(fromProperty: 'children', fromClass: AttachmentType::class)
+ ],
+ normalizationContext: ['groups' => ['attachment_type:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
+)]
+#[ApiFilter(PropertyFilter::class)]
+#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
+#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
+#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class AttachmentType extends AbstractStructuralDBElement
{
- /**
- * @ORM\OneToMany(targetEntity="AttachmentType", mappedBy="parent", cascade={"persist"})
- * @ORM\OrderBy({"name" = "ASC"})
- */
- protected $children;
+ #[ORM\OneToMany(mappedBy: 'parent', targetEntity: AttachmentType::class, cascade: ['persist'])]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ protected Collection $children;
+
+ #[ORM\ManyToOne(targetEntity: AttachmentType::class, inversedBy: 'children')]
+ #[ORM\JoinColumn(name: 'parent_id')]
+ #[Groups(['attachment_type:read', 'attachment_type:write'])]
+ #[ApiProperty(readableLink: true, writableLink: false)]
+ protected ?AbstractStructuralDBElement $parent = null;
/**
- * @ORM\ManyToOne(targetEntity="AttachmentType", inversedBy="children")
- * @ORM\JoinColumn(name="parent_id", referencedColumnName="id")
- */
- protected $parent;
-
- /**
- * @var string
- * @ORM\Column(type="text")
- * @ValidFileFilter
+ * @var string A comma separated list of file types, which are allowed for attachment files.
+ * Must be in the format of accept attribute
+ * (See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers).
*/
+ #[ORM\Column(type: Types::TEXT)]
+ #[ValidFileFilter]
+ #[Groups(['attachment_type:read', 'attachment_type:write', 'import', 'extended'])]
protected string $filetype_filter = '';
+
/**
* @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Attachments\AttachmentTypeAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"name" = "ASC"})
- * @Assert\Valid()
*/
- protected $attachments;
+ #[Assert\Valid]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: AttachmentTypeAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ #[Groups(['attachment_type:read', 'attachment_type:write', 'import', 'full'])]
+ protected Collection $attachments;
+
+ #[ORM\ManyToOne(targetEntity: AttachmentTypeAttachment::class)]
+ #[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
+ #[Groups(['attachment_type:read', 'attachment_type:write', 'full'])]
+ protected ?Attachment $master_picture_attachment = null;
/** @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Parameters\AttachmentTypeParameter", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"group" = "ASC" ,"name" = "ASC"})
- * @Assert\Valid()
*/
- protected $parameters;
+ #[Assert\Valid]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: AttachmentTypeParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
+ #[Groups(['attachment_type:read', 'attachment_type:write', 'import', 'full'])]
+ protected Collection $parameters;
/**
- * @var Collection
- * @ORM\OneToMany(targetEntity="Attachment", mappedBy="attachment_type")
+ * @var Collection
*/
- protected $attachments_with_type;
+ #[ORM\OneToMany(mappedBy: 'attachment_type', targetEntity: Attachment::class)]
+ protected Collection $attachments_with_type;
+
+ #[Groups(['attachment_type:read'])]
+ protected ?\DateTimeImmutable $addedDate = null;
+ #[Groups(['attachment_type:read'])]
+ protected ?\DateTimeImmutable $lastModified = null;
+
public function __construct()
{
+ $this->children = new ArrayCollection();
+ $this->parameters = new ArrayCollection();
parent::__construct();
$this->attachments = new ArrayCollection();
$this->attachments_with_type = new ArrayCollection();
@@ -90,8 +152,9 @@ class AttachmentType extends AbstractStructuralDBElement
/**
* Get all attachments ("Attachment" objects) with this type.
*
- * @return Collection|Attachment[] all attachments with this type, as a one-dimensional array of Attachments
+ * @return Collection all attachments with this type, as a one-dimensional array of Attachments
* (sorted by their names)
+ * @phpstan-return Collection
*/
public function getAttachmentsForType(): Collection
{
@@ -99,7 +162,7 @@ class AttachmentType extends AbstractStructuralDBElement
}
/**
- * Gets an filter, which file types are allowed for attachment files.
+ * Gets a filter, which file types are allowed for attachment files.
* Must be in the format of accept attribute
* (See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers).
*/
diff --git a/src/Entity/Attachments/AttachmentTypeAttachment.php b/src/Entity/Attachments/AttachmentTypeAttachment.php
index 2a21405c..1e677ff0 100644
--- a/src/Entity/Attachments/AttachmentTypeAttachment.php
+++ b/src/Entity/Attachments/AttachmentTypeAttachment.php
@@ -22,22 +22,25 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
/**
* A attachment attached to an attachmentType element.
- *
- * @ORM\Entity()
- * @UniqueEntity({"name", "attachment_type", "element"})
+ * @extends Attachment
*/
+#[UniqueEntity(['name', 'attachment_type', 'element'])]
+#[ORM\Entity]
class AttachmentTypeAttachment extends Attachment
{
- public const ALLOWED_ELEMENT_CLASS = AttachmentType::class;
+ final public const ALLOWED_ELEMENT_CLASS = AttachmentType::class;
/**
- * @var AttachmentType the element this attachment is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\Attachments\AttachmentType", inversedBy="attachments")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
+ * @var AttachmentContainingDBElement|null the element this attachment is associated with
*/
+ #[ORM\ManyToOne(targetEntity: AttachmentType::class, inversedBy: 'attachments')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
protected ?AttachmentContainingDBElement $element = null;
}
diff --git a/src/Entity/Attachments/AttachmentUpload.php b/src/Entity/Attachments/AttachmentUpload.php
new file mode 100644
index 00000000..f2b042b7
--- /dev/null
+++ b/src/Entity/Attachments/AttachmentUpload.php
@@ -0,0 +1,77 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Entity\Attachments;
+
+use Symfony\Component\Form\FormInterface;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
+use Symfony\Component\Serializer\Attribute\Groups;
+
+/**
+ * This is a DTO representing a file upload for an attachment and which is used to pass data to the Attachment
+ * submit handler service.
+ */
+class AttachmentUpload
+{
+ public function __construct(
+ /** @var UploadedFile|null The file which was uploaded, or null if the file should not be changed */
+ public readonly ?UploadedFile $file,
+ /** @var string|null The base64 encoded data of the file which should be uploaded. */
+ #[Groups(['attachment:write'])]
+ public readonly ?string $data = null,
+ /** @vaar string|null The original filename of the file passed in data. */
+ #[Groups(['attachment:write'])]
+ public readonly ?string $filename = null,
+ /** @var bool True, if the URL in the attachment should be downloaded by Part-DB */
+ #[Groups(['attachment:write'])]
+ public readonly bool $downloadUrl = false,
+ /** @var bool If true the file will be moved to private attachment storage,
+ * if false it will be moved to public attachment storage. On null file is not moved
+ */
+ #[Groups(['attachment:write'])]
+ public readonly ?bool $private = null,
+ /** @var bool If true and no preview image was set yet, the new uploaded file will become the preview image */
+ #[Groups(['attachment:write'])]
+ public readonly ?bool $becomePreviewIfEmpty = true,
+ ) {
+ }
+
+ /**
+ * Creates an AttachmentUpload object from an Attachment FormInterface
+ * @param FormInterface $form
+ * @return AttachmentUpload
+ */
+ public static function fromAttachmentForm(FormInterface $form): AttachmentUpload
+ {
+ if (!$form->has('file')) {
+ throw new \InvalidArgumentException('The form does not have a file field. Is it an attachment form?');
+ }
+
+ return new self(
+ file: $form->get('file')->getData(),
+ downloadUrl: $form->get('downloadURL')->getData(),
+ private: $form->get('secureFile')->getData()
+ );
+
+ }
+}
\ No newline at end of file
diff --git a/src/Entity/Attachments/CategoryAttachment.php b/src/Entity/Attachments/CategoryAttachment.php
index e4d38137..3bea265e 100644
--- a/src/Entity/Attachments/CategoryAttachment.php
+++ b/src/Entity/Attachments/CategoryAttachment.php
@@ -23,22 +23,25 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
use App\Entity\Parts\Category;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
/**
- * A attachment attached to a category element.
- *
- * @ORM\Entity()
- * @UniqueEntity({"name", "attachment_type", "element"})
+ * An attachment attached to a category element.
+ * @extends Attachment
*/
+#[UniqueEntity(['name', 'attachment_type', 'element'])]
+#[ORM\Entity]
class CategoryAttachment extends Attachment
{
- public const ALLOWED_ELEMENT_CLASS = Category::class;
+ final public const ALLOWED_ELEMENT_CLASS = Category::class;
/**
- * @var Category the element this attachment is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\Parts\Category", inversedBy="attachments")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
+ * @var AttachmentContainingDBElement|null the element this attachment is associated with
*/
+ #[ORM\ManyToOne(targetEntity: Category::class, inversedBy: 'attachments')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
protected ?AttachmentContainingDBElement $element = null;
}
diff --git a/src/Entity/Attachments/CurrencyAttachment.php b/src/Entity/Attachments/CurrencyAttachment.php
index 73ad1145..a5d6e061 100644
--- a/src/Entity/Attachments/CurrencyAttachment.php
+++ b/src/Entity/Attachments/CurrencyAttachment.php
@@ -23,22 +23,26 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
use App\Entity\PriceInformations\Currency;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
/**
* An attachment attached to a currency element.
- *
- * @ORM\Entity()
- * @UniqueEntity({"name", "attachment_type", "element"})
+ * @extends Attachment
*/
+#[UniqueEntity(['name', 'attachment_type', 'element'])]
+#[ORM\Entity]
class CurrencyAttachment extends Attachment
{
- public const ALLOWED_ELEMENT_CLASS = Currency::class;
+ final public const ALLOWED_ELEMENT_CLASS = Currency::class;
+
/**
- * @var Currency the element this attachment is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\PriceInformations\Currency", inversedBy="attachments")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
+ * @var Currency|null the element this attachment is associated with
*/
+ #[ORM\ManyToOne(targetEntity: Currency::class, inversedBy: 'attachments')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
protected ?AttachmentContainingDBElement $element = null;
}
diff --git a/src/Entity/Attachments/FootprintAttachment.php b/src/Entity/Attachments/FootprintAttachment.php
index 84ba3fb7..4a9b866c 100644
--- a/src/Entity/Attachments/FootprintAttachment.php
+++ b/src/Entity/Attachments/FootprintAttachment.php
@@ -23,22 +23,26 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
use App\Entity\Parts\Footprint;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
/**
- * A attachment attached to a footprint element.
- *
- * @ORM\Entity()
- * @UniqueEntity({"name", "attachment_type", "element"})
+ * An attachment attached to a footprint element.
+ * @extends Attachment
*/
+#[UniqueEntity(['name', 'attachment_type', 'element'])]
+#[ORM\Entity]
class FootprintAttachment extends Attachment
{
- public const ALLOWED_ELEMENT_CLASS = Footprint::class;
+ final public const ALLOWED_ELEMENT_CLASS = Footprint::class;
+
/**
- * @var Footprint the element this attachment is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\Parts\Footprint", inversedBy="attachments")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
+ * @var Footprint|null the element this attachment is associated with
*/
+ #[ORM\ManyToOne(targetEntity: Footprint::class, inversedBy: 'attachments')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
protected ?AttachmentContainingDBElement $element = null;
}
diff --git a/src/Entity/Attachments/GroupAttachment.php b/src/Entity/Attachments/GroupAttachment.php
index e64c5745..e9bf947f 100644
--- a/src/Entity/Attachments/GroupAttachment.php
+++ b/src/Entity/Attachments/GroupAttachment.php
@@ -23,22 +23,26 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
use App\Entity\UserSystem\Group;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
/**
- * A attachment attached to a Group element.
- *
- * @ORM\Entity()
- * @UniqueEntity({"name", "attachment_type", "element"})
+ * An attachment attached to a Group element.
+ * @extends Attachment
*/
+#[UniqueEntity(['name', 'attachment_type', 'element'])]
+#[ORM\Entity]
class GroupAttachment extends Attachment
{
- public const ALLOWED_ELEMENT_CLASS = Group::class;
+ final public const ALLOWED_ELEMENT_CLASS = Group::class;
+
/**
- * @var Group the element this attachment is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\UserSystem\Group", inversedBy="attachments")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
+ * @var Group|null the element this attachment is associated with
*/
+ #[ORM\ManyToOne(targetEntity: Group::class, inversedBy: 'attachments')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
protected ?AttachmentContainingDBElement $element = null;
}
diff --git a/src/Entity/Attachments/LabelAttachment.php b/src/Entity/Attachments/LabelAttachment.php
index f0f70b79..b8891ced 100644
--- a/src/Entity/Attachments/LabelAttachment.php
+++ b/src/Entity/Attachments/LabelAttachment.php
@@ -42,23 +42,26 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
use App\Entity\LabelSystem\LabelProfile;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
/**
* A attachment attached to a user element.
- *
- * @ORM\Entity()
- * @UniqueEntity({"name", "attachment_type", "element"})
+ * @extends Attachment
*/
+#[UniqueEntity(['name', 'attachment_type', 'element'])]
+#[ORM\Entity]
class LabelAttachment extends Attachment
{
- public const ALLOWED_ELEMENT_CLASS = LabelProfile::class;
+ final public const ALLOWED_ELEMENT_CLASS = LabelProfile::class;
/**
* @var LabelProfile the element this attachment is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\LabelSystem\LabelProfile", inversedBy="attachments")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
*/
+ #[ORM\ManyToOne(targetEntity: LabelProfile::class, inversedBy: 'attachments')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
protected ?AttachmentContainingDBElement $element = null;
}
diff --git a/src/Entity/Attachments/ManufacturerAttachment.php b/src/Entity/Attachments/ManufacturerAttachment.php
index 0d113977..1b8891e5 100644
--- a/src/Entity/Attachments/ManufacturerAttachment.php
+++ b/src/Entity/Attachments/ManufacturerAttachment.php
@@ -23,22 +23,26 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
use App\Entity\Parts\Manufacturer;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
/**
- * A attachment attached to a manufacturer element.
- *
- * @ORM\Entity()
- * @UniqueEntity({"name", "attachment_type", "element"})
+ * An attachment attached to a manufacturer element.
+ * @extends Attachment
*/
+#[UniqueEntity(['name', 'attachment_type', 'element'])]
+#[ORM\Entity]
class ManufacturerAttachment extends Attachment
{
- public const ALLOWED_ELEMENT_CLASS = Manufacturer::class;
+ final public const ALLOWED_ELEMENT_CLASS = Manufacturer::class;
+
/**
- * @var Manufacturer the element this attachment is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\Parts\Manufacturer", inversedBy="attachments")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
+ * @var Manufacturer|null the element this attachment is associated with
*/
+ #[ORM\ManyToOne(targetEntity: Manufacturer::class, inversedBy: 'attachments')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
protected ?AttachmentContainingDBElement $element = null;
}
diff --git a/src/Entity/Attachments/MeasurementUnitAttachment.php b/src/Entity/Attachments/MeasurementUnitAttachment.php
index 0c24f649..dbc8829e 100644
--- a/src/Entity/Attachments/MeasurementUnitAttachment.php
+++ b/src/Entity/Attachments/MeasurementUnitAttachment.php
@@ -22,24 +22,25 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
-use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
/**
- * A attachment attached to a measurement unit element.
- *
- * @ORM\Entity()
- * @UniqueEntity({"name", "attachment_type", "element"})
+ * An attachment attached to a measurement unit element.
+ * @extends Attachment
*/
+#[UniqueEntity(['name', 'attachment_type', 'element'])]
+#[ORM\Entity]
class MeasurementUnitAttachment extends Attachment
{
- public const ALLOWED_ELEMENT_CLASS = MeasurementUnit::class;
- /**
- * @var Manufacturer the element this attachment is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\Parts\MeasurementUnit", inversedBy="attachments")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
- */
+ final public const ALLOWED_ELEMENT_CLASS = MeasurementUnit::class;
+
+
+ #[ORM\ManyToOne(targetEntity: MeasurementUnit::class, inversedBy: 'attachments')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
protected ?AttachmentContainingDBElement $element = null;
}
diff --git a/src/Entity/Attachments/PartAttachment.php b/src/Entity/Attachments/PartAttachment.php
index 3aa2d05c..0873b3c5 100644
--- a/src/Entity/Attachments/PartAttachment.php
+++ b/src/Entity/Attachments/PartAttachment.php
@@ -23,22 +23,25 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
use App\Entity\Parts\Part;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
/**
* A attachment attached to a part element.
- *
- * @ORM\Entity()
- * @UniqueEntity({"name", "attachment_type", "element"})
+ * @extends Attachment
*/
+#[UniqueEntity(['name', 'attachment_type', 'element'])]
+#[ORM\Entity]
class PartAttachment extends Attachment
{
- public const ALLOWED_ELEMENT_CLASS = Part::class;
+ final public const ALLOWED_ELEMENT_CLASS = Part::class;
/**
* @var Part the element this attachment is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\Parts\Part", inversedBy="attachments")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
*/
+ #[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'attachments')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
protected ?AttachmentContainingDBElement $element = null;
}
diff --git a/src/Entity/Attachments/ProjectAttachment.php b/src/Entity/Attachments/ProjectAttachment.php
index 1fab6bc8..f3e96292 100644
--- a/src/Entity/Attachments/ProjectAttachment.php
+++ b/src/Entity/Attachments/ProjectAttachment.php
@@ -23,22 +23,25 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
use App\Entity\ProjectSystem\Project;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
/**
* A attachment attached to a device element.
- *
- * @ORM\Entity()
- * @UniqueEntity({"name", "attachment_type", "element"})
+ * @extends Attachment
*/
+#[UniqueEntity(['name', 'attachment_type', 'element'])]
+#[ORM\Entity]
class ProjectAttachment extends Attachment
{
- public const ALLOWED_ELEMENT_CLASS = Project::class;
+ final public const ALLOWED_ELEMENT_CLASS = Project::class;
/**
- * @var Project the element this attachment is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\ProjectSystem\Project", inversedBy="attachments")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
+ * @var Project|null the element this attachment is associated with
*/
+ #[ORM\ManyToOne(targetEntity: Project::class, inversedBy: 'attachments')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
protected ?AttachmentContainingDBElement $element = null;
}
diff --git a/src/Entity/Attachments/StorelocationAttachment.php b/src/Entity/Attachments/StorageLocationAttachment.php
similarity index 57%
rename from src/Entity/Attachments/StorelocationAttachment.php
rename to src/Entity/Attachments/StorageLocationAttachment.php
index e2b9025a..3cd82d0c 100644
--- a/src/Entity/Attachments/StorelocationAttachment.php
+++ b/src/Entity/Attachments/StorageLocationAttachment.php
@@ -22,23 +22,27 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
-use App\Entity\Parts\Storelocation;
+use App\Entity\Parts\StorageLocation;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
/**
- * A attachment attached to a measurement unit element.
- *
- * @ORM\Entity()
- * @UniqueEntity({"name", "attachment_type", "element"})
+ * An attachment attached to a measurement unit element.
+ * @extends Attachment
*/
-class StorelocationAttachment extends Attachment
+#[UniqueEntity(['name', 'attachment_type', 'element'])]
+#[ORM\Entity]
+class StorageLocationAttachment extends Attachment
{
- public const ALLOWED_ELEMENT_CLASS = Storelocation::class;
+ final public const ALLOWED_ELEMENT_CLASS = StorageLocation::class;
+
/**
- * @var Storelocation the element this attachment is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\Parts\Storelocation", inversedBy="attachments")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
+ * @var StorageLocation|null the element this attachment is associated with
*/
+ #[ORM\ManyToOne(targetEntity: StorageLocation::class, inversedBy: 'attachments')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
protected ?AttachmentContainingDBElement $element = null;
}
diff --git a/src/Entity/Attachments/SupplierAttachment.php b/src/Entity/Attachments/SupplierAttachment.php
index 59893b74..c3adc438 100644
--- a/src/Entity/Attachments/SupplierAttachment.php
+++ b/src/Entity/Attachments/SupplierAttachment.php
@@ -23,22 +23,26 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
use App\Entity\Parts\Supplier;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
/**
* A attachment attached to a supplier element.
- *
- * @ORM\Entity()
- * @UniqueEntity({"name", "attachment_type", "element"})
+ * @extends Attachment
*/
+#[UniqueEntity(['name', 'attachment_type', 'element'])]
+#[ORM\Entity]
class SupplierAttachment extends Attachment
{
- public const ALLOWED_ELEMENT_CLASS = Supplier::class;
+ final public const ALLOWED_ELEMENT_CLASS = Supplier::class;
+
/**
- * @var Supplier the element this attachment is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\Parts\Supplier", inversedBy="attachments")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
+ * @var Supplier|null the element this attachment is associated with
*/
+ #[ORM\ManyToOne(targetEntity: Supplier::class, inversedBy: 'attachments')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
protected ?AttachmentContainingDBElement $element = null;
}
diff --git a/src/Entity/Attachments/UserAttachment.php b/src/Entity/Attachments/UserAttachment.php
index 84a04c17..b031d419 100644
--- a/src/Entity/Attachments/UserAttachment.php
+++ b/src/Entity/Attachments/UserAttachment.php
@@ -23,22 +23,26 @@ declare(strict_types=1);
namespace App\Entity\Attachments;
use App\Entity\UserSystem\User;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
/**
- * A attachment attached to a user element.
- *
- * @ORM\Entity()
- * @UniqueEntity({"name", "attachment_type", "element"})
+ * An attachment attached to a user element.
+ * @extends Attachment
*/
+#[UniqueEntity(['name', 'attachment_type', 'element'])]
+#[ORM\Entity]
class UserAttachment extends Attachment
{
- public const ALLOWED_ELEMENT_CLASS = User::class;
+ final public const ALLOWED_ELEMENT_CLASS = User::class;
+
/**
- * @var User the element this attachment is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\UserSystem\User", inversedBy="attachments")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
+ * @var User|null the element this attachment is associated with
*/
+ #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'attachments')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
protected ?AttachmentContainingDBElement $element = null;
}
diff --git a/src/Entity/Base/AbstractCompany.php b/src/Entity/Base/AbstractCompany.php
index 7325bab6..947d1339 100644
--- a/src/Entity/Base/AbstractCompany.php
+++ b/src/Entity/Base/AbstractCompany.php
@@ -22,53 +22,80 @@ declare(strict_types=1);
namespace App\Entity\Base;
+use App\Entity\Attachments\Attachment;
+use App\Entity\Parameters\AbstractParameter;
+use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Serializer\Annotation\Groups;
use function is_string;
use Symfony\Component\Validator\Constraints as Assert;
/**
* This abstract class is used for companies like suppliers or manufacturers.
*
- * @ORM\MappedSuperclass()
+ * @template AT of Attachment
+ * @template PT of AbstractParameter
+ * @extends AbstractPartsContainingDBElement
*/
+#[ORM\MappedSuperclass]
abstract class AbstractCompany extends AbstractPartsContainingDBElement
{
+ #[Groups(['company:read'])]
+ protected ?\DateTimeImmutable $addedDate = null;
+ #[Groups(['company:read'])]
+ protected ?\DateTimeImmutable $lastModified = null;
+
/**
* @var string The address of the company
- * @ORM\Column(type="string")
*/
+ #[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
+ #[ORM\Column(type: Types::STRING)]
+ #[Assert\Length(max: 255)]
protected string $address = '';
/**
* @var string The phone number of the company
- * @ORM\Column(type="string")
*/
+ #[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
+ #[ORM\Column(type: Types::STRING)]
+ #[Assert\Length(max: 255)]
protected string $phone_number = '';
/**
* @var string The fax number of the company
- * @ORM\Column(type="string")
*/
+ #[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
+ #[ORM\Column(type: Types::STRING)]
+ #[Assert\Length(max: 255)]
protected string $fax_number = '';
/**
* @var string The email address of the company
- * @ORM\Column(type="string")
- * @Assert\Email()
*/
+ #[Assert\Email]
+ #[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
+ #[ORM\Column(type: Types::STRING)]
+ #[Assert\Length(max: 255)]
protected string $email_address = '';
/**
* @var string The website of the company
- * @ORM\Column(type="string")
- * @Assert\Url()
*/
+ #[Assert\Url]
+ #[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
+ #[ORM\Column(type: Types::STRING)]
+ #[Assert\Length(max: 255)]
protected string $website = '';
+ #[Groups(['company:read', 'company:write', 'import', 'full', 'extended'])]
+ protected string $comment = '';
+
/**
- * @var string
- * @ORM\Column(type="string")
+ * @var string The link to the website of an article. Use %PARTNUMBER% as placeholder for the part number.
*/
+ #[ORM\Column(type: Types::STRING)]
+ #[Assert\Length(max: 255)]
+ #[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
protected string $auto_product_url = '';
/********************************************************************************
@@ -135,7 +162,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
*
* @return string the link to the article
*/
- public function getAutoProductUrl(string $partnr = null): string
+ public function getAutoProductUrl(?string $partnr = null): string
{
if (is_string($partnr)) {
return str_replace('%PARTNUMBER%', $partnr, $this->auto_product_url);
diff --git a/src/Entity/Base/AbstractDBElement.php b/src/Entity/Base/AbstractDBElement.php
index dd736eac..871a22d0 100644
--- a/src/Entity/Base/AbstractDBElement.php
+++ b/src/Entity/Base/AbstractDBElement.php
@@ -22,6 +22,38 @@ declare(strict_types=1);
namespace App\Entity\Base;
+use App\Entity\Attachments\AttachmentType;
+use App\Entity\Attachments\Attachment;
+use App\Entity\Attachments\AttachmentTypeAttachment;
+use App\Entity\Attachments\CategoryAttachment;
+use App\Entity\Attachments\CurrencyAttachment;
+use App\Entity\Attachments\FootprintAttachment;
+use App\Entity\Attachments\GroupAttachment;
+use App\Entity\Attachments\LabelAttachment;
+use App\Entity\Attachments\ManufacturerAttachment;
+use App\Entity\Attachments\MeasurementUnitAttachment;
+use App\Entity\Attachments\PartAttachment;
+use App\Entity\Attachments\ProjectAttachment;
+use App\Entity\Attachments\StorageLocationAttachment;
+use App\Entity\Attachments\SupplierAttachment;
+use App\Entity\Attachments\UserAttachment;
+use App\Entity\Parameters\AbstractParameter;
+use App\Entity\Parts\Category;
+use App\Entity\ProjectSystem\Project;
+use App\Entity\ProjectSystem\ProjectBOMEntry;
+use App\Entity\Parts\Footprint;
+use App\Entity\UserSystem\Group;
+use App\Entity\Parts\Manufacturer;
+use App\Entity\PriceInformations\Orderdetail;
+use App\Entity\Parts\Part;
+use App\Entity\Parts\StorageLocation;
+use App\Entity\Parts\PartLot;
+use App\Entity\PriceInformations\Currency;
+use App\Entity\Parts\MeasurementUnit;
+use App\Entity\Parts\Supplier;
+use App\Entity\UserSystem\User;
+use App\Repository\DBElementRepository;
+use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use JsonSerializable;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
@@ -34,35 +66,18 @@ use Symfony\Component\Serializer\Annotation\Groups;
* (except special tables like "internal"...)
* Every database table which are managed with this class (or a subclass of it)
* must have the table row "id"!! The ID is the unique key to identify the elements.
- *
- * @ORM\MappedSuperclass(repositoryClass="App\Repository\DBElementRepository")
- *
- * @DiscriminatorMap(typeProperty="type", mapping={
- * "attachment_type" = "App\Entity\AttachmentType",
- * "attachment" = "App\Entity\Attachment",
- * "category" = "App\Entity\Attachment",
- * "project" = "App\Entity\ProjectSystem\Project",
- * "project_bom_entry" = "App\Entity\ProjectSystem\ProjectBOMEntry",
- * "footprint" = "App\Entity\Footprint",
- * "group" = "App\Entity\Group",
- * "manufacturer" = "App\Entity\Manufacturer",
- * "orderdetail" = "App\Entity\Orderdetail",
- * "part" = "App\Entity\Part",
- * "pricedetail" = "App\Entity\Pricedetail",
- * "storelocation" = "App\Entity\Storelocation",
- * "supplier" = "App\Entity\Supplier",
- * "user" = "App\Entity\User"
- * })
*/
+#[DiscriminatorMap(typeProperty: 'type', mapping: ['attachment_type' => AttachmentType::class, 'attachment' => Attachment::class, 'attachment_type_attachment' => AttachmentTypeAttachment::class, 'category_attachment' => CategoryAttachment::class, 'currency_attachment' => CurrencyAttachment::class, 'footprint_attachment' => FootprintAttachment::class, 'group_attachment' => GroupAttachment::class, 'label_attachment' => LabelAttachment::class, 'manufacturer_attachment' => ManufacturerAttachment::class, 'measurement_unit_attachment' => MeasurementUnitAttachment::class, 'part_attachment' => PartAttachment::class, 'project_attachment' => ProjectAttachment::class, 'storelocation_attachment' => StorageLocationAttachment::class, 'supplier_attachment' => SupplierAttachment::class, 'user_attachment' => UserAttachment::class, 'category' => Category::class, 'project' => Project::class, 'project_bom_entry' => ProjectBOMEntry::class, 'footprint' => Footprint::class, 'group' => Group::class, 'manufacturer' => Manufacturer::class, 'orderdetail' => Orderdetail::class, 'part' => Part::class, 'pricedetail' => 'App\Entity\PriceInformation\Pricedetail', 'storelocation' => StorageLocation::class, 'part_lot' => PartLot::class, 'currency' => Currency::class, 'measurement_unit' => MeasurementUnit::class, 'parameter' => AbstractParameter::class, 'supplier' => Supplier::class, 'user' => User::class])]
+#[ORM\MappedSuperclass(repositoryClass: DBElementRepository::class)]
abstract class AbstractDBElement implements JsonSerializable
{
/** @var int|null The Identification number for this part. This value is unique for the element in this table.
* Null if the element is not saved to DB yet.
- * @ORM\Column(type="integer")
- * @ORM\Id()
- * @ORM\GeneratedValue()
- * @Groups({"full"})
*/
+ #[Groups(['full', 'api:basic:read'])]
+ #[ORM\Column(type: Types::INTEGER)]
+ #[ORM\Id]
+ #[ORM\GeneratedValue]
protected ?int $id = null;
public function __clone()
diff --git a/src/Entity/Base/AbstractNamedDBElement.php b/src/Entity/Base/AbstractNamedDBElement.php
index 50c78f41..f7939589 100644
--- a/src/Entity/Base/AbstractNamedDBElement.php
+++ b/src/Entity/Base/AbstractNamedDBElement.php
@@ -22,6 +22,8 @@ declare(strict_types=1);
namespace App\Entity\Base;
+use App\Repository\NamedDBElementRepository;
+use Doctrine\DBAL\Types\Types;
use App\Entity\Contracts\NamedElementInterface;
use App\Entity\Contracts\TimeStampableInterface;
use Doctrine\ORM\Mapping as ORM;
@@ -30,20 +32,20 @@ use Symfony\Component\Validator\Constraints as Assert;
/**
* All subclasses of this class have an attribute "name".
- *
- * @ORM\MappedSuperclass(repositoryClass="App\Repository\NamedDBElement")
- * @ORM\HasLifecycleCallbacks()
*/
-abstract class AbstractNamedDBElement extends AbstractDBElement implements NamedElementInterface, TimeStampableInterface
+#[ORM\MappedSuperclass(repositoryClass: NamedDBElementRepository::class)]
+#[ORM\HasLifecycleCallbacks]
+abstract class AbstractNamedDBElement extends AbstractDBElement implements NamedElementInterface, TimeStampableInterface, \Stringable
{
use TimestampTrait;
/**
- * @var string the name of this element
- * @ORM\Column(type="string")
- * @Assert\NotBlank()
- * @Groups({"simple", "extended", "full"})
+ * @var string The name of this element
*/
+ #[Assert\NotBlank]
+ #[Groups(['simple', 'extended', 'full', 'import', 'api:basic:read', 'api:basic:write'])]
+ #[ORM\Column(type: Types::STRING)]
+ #[Assert\Length(max: 255)]
protected string $name = '';
/******************************************************************************
@@ -52,7 +54,7 @@ abstract class AbstractNamedDBElement extends AbstractDBElement implements Named
*
******************************************************************************/
- public function __toString()
+ public function __toString(): string
{
return $this->getName();
}
diff --git a/src/Entity/Base/AbstractPartsContainingDBElement.php b/src/Entity/Base/AbstractPartsContainingDBElement.php
index f30819f5..70d88fa9 100644
--- a/src/Entity/Base/AbstractPartsContainingDBElement.php
+++ b/src/Entity/Base/AbstractPartsContainingDBElement.php
@@ -22,14 +22,28 @@ declare(strict_types=1);
namespace App\Entity\Base;
+use App\Entity\Attachments\Attachment;
+use App\Entity\Parameters\AbstractParameter;
+use App\Repository\AbstractPartsContainingRepository;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Serializer\Annotation\Groups;
/**
- * Class PartsContainingDBElement.
- *
- * @ORM\MappedSuperclass(repositoryClass="App\Repository\AbstractPartsContainingRepository")
+ * @template AT of Attachment
+ * @template PT of AbstractParameter
+ * @extends AbstractStructuralDBElement
*/
-abstract class
-AbstractPartsContainingDBElement extends AbstractStructuralDBElement
+#[ORM\MappedSuperclass(repositoryClass: AbstractPartsContainingRepository::class)]
+abstract class AbstractPartsContainingDBElement extends AbstractStructuralDBElement
{
+ #[Groups(['full', 'import'])]
+ protected Collection $parameters;
+
+ public function __construct()
+ {
+ parent::__construct();
+ $this->parameters = new ArrayCollection();
+ }
}
diff --git a/src/Entity/Base/AbstractStructuralDBElement.php b/src/Entity/Base/AbstractStructuralDBElement.php
index cbe2c7be..660710db 100644
--- a/src/Entity/Base/AbstractStructuralDBElement.php
+++ b/src/Entity/Base/AbstractStructuralDBElement.php
@@ -22,14 +22,21 @@ declare(strict_types=1);
namespace App\Entity\Base;
+use App\Entity\Attachments\Attachment;
+use App\Entity\Parameters\AbstractParameter;
+use App\Repository\StructuralDBElementRepository;
+use App\EntityListeners\TreeCacheInvalidationListener;
+use App\Validator\Constraints\UniqueObjectCollection;
+use Doctrine\DBAL\Types\Types;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Parameters\ParametersTrait;
use App\Validator\Constraints\NoneOfItsChildren;
+use Symfony\Component\Serializer\Annotation\SerializedName;
+use Symfony\Component\Validator\Constraints as Assert;
use function count;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
-use function get_class;
use InvalidArgumentException;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Groups;
@@ -43,35 +50,40 @@ use Symfony\Component\Serializer\Annotation\Groups;
* It's allowed to have instances of root elements, but if you try to change
* an attribute of a root element, you will get an exception!
*
- * @ORM\MappedSuperclass(repositoryClass="App\Repository\StructuralDBElementRepository")
*
- * @ORM\EntityListeners({"App\EntityListeners\TreeCacheInvalidationListener"})
+ * @see \App\Tests\Entity\Base\AbstractStructuralDBElementTest
*
- * @UniqueEntity(fields={"name", "parent"}, ignoreNull=false, message="structural.entity.unique_name")
+ * @template AT of Attachment
+ * @template PT of AbstractParameter
+ * @template-use ParametersTrait
+ * @extends AttachmentContainingDBElement
+ * @uses ParametersTrait
*/
+#[UniqueEntity(fields: ['name', 'parent'], message: 'structural.entity.unique_name', ignoreNull: false)]
+#[ORM\MappedSuperclass(repositoryClass: StructuralDBElementRepository::class)]
+#[ORM\EntityListeners([TreeCacheInvalidationListener::class])]
abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
{
use ParametersTrait;
- public const ID_ROOT_ELEMENT = 0;
-
/**
* This is a not standard character, so build a const, so a dev can easily use it.
*/
- public const PATH_DELIMITER_ARROW = ' → ';
+ final public const PATH_DELIMITER_ARROW = ' → ';
/**
- * @var string The comment info for this element
- * @ORM\Column(type="text")
- * @Groups({"simple", "extended", "full"})
+ * @var string The comment info for this element as markdown
*/
+ #[Groups(['full', 'import'])]
+ #[ORM\Column(type: Types::TEXT)]
protected string $comment = '';
/**
* @var bool If this property is set, this element can not be selected for part properties.
* Useful if this element should be used only for grouping, sorting.
- * @ORM\Column(type="boolean")
*/
+ #[Groups(['full', 'import'])]
+ #[ORM\Column(type: Types::BOOLEAN)]
protected bool $not_selectable = false;
/**
@@ -80,26 +92,44 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
protected int $level = 0;
/**
- * We can not define the mapping here or we will get an exception. Unfortunately we have to do the mapping in the
+ * We can not define the mapping here, or we will get an exception. Unfortunately we have to do the mapping in the
* subclasses.
*
- * @var AbstractStructuralDBElement[]|Collection
- * @Groups({"include_children"})
+ * @var Collection
+ * @phpstan-var Collection
*/
- protected $children;
+ #[Groups(['include_children'])]
+ protected Collection $children;
/**
- * @var AbstractStructuralDBElement
- * @NoneOfItsChildren()
- * @Groups({"include_parents"})
+ * @var AbstractStructuralDBElement|null
+ * @phpstan-var static|null
*/
- protected $parent = null;
+ #[Groups(['include_parents', 'import'])]
+ #[NoneOfItsChildren]
+ protected ?AbstractStructuralDBElement $parent = null;
- /** @var string[] all names of all parent elements as a array of strings,
+ /**
+ * Mapping done in subclasses.
+ *
+ * @var Collection
+ * @phpstan-var Collection
+ */
+ #[Assert\Valid]
+ #[UniqueObjectCollection(fields: ['name', 'group', 'element'])]
+ protected Collection $parameters;
+
+ /** @var string[] all names of all parent elements as an array of strings,
* the last array element is the name of the element itself
*/
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();
@@ -140,11 +170,11 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
//Check if both elements compared, are from the same type
// (we have to check inheritance, or we get exceptions when using doctrine entities (they have a proxy type):
- if (!is_a($another_element, $class_name) && !is_a($this, get_class($another_element))) {
+ if (!$another_element instanceof $class_name && !is_a($this, $another_element::class)) {
throw new InvalidArgumentException('isChildOf() only works for objects of the same type!');
}
- if (null === $this->getParent()) { // this is the root node
+ if (!$this->getParent() instanceof self) { // this is the root node
return false;
}
@@ -154,10 +184,8 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
if ($this->getParent() === $another_element) {
return true;
}
- } else { //If the IDs are defined, we can compare the IDs
- if ($this->getParent()->getID() === $another_element->getID()) {
- return true;
- }
+ } elseif ($this->getParent()->getID() === $another_element->getID()) {
+ return true;
}
//Otherwise, check recursively
@@ -167,11 +195,11 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
/**
* Checks if this element is an root element (has no parent).
*
- * @return bool true if the this element is an root element
+ * @return bool true if this element is a root element
*/
public function isRoot(): bool
{
- return null === $this->parent;
+ return $this->parent === null;
}
/******************************************************************************
@@ -183,7 +211,7 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
/**
* Get the parent of this element.
*
- * @return AbstractStructuralDBElement|null The parent element. Null if this element, does not have a parent.
+ * @return static|null The parent element. Null if this element, does not have a parent.
*/
public function getParent(): ?self
{
@@ -191,7 +219,7 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
}
/**
- * Get the comment of the element.
+ * Get the comment of the element as markdown encoded string.
*
* @return string the comment
@@ -214,9 +242,9 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
/*
* Only check for nodes that have a parent. In the other cases zero is correct.
*/
- if (0 === $this->level && null !== $this->parent) {
+ if (0 === $this->level && $this->parent instanceof self) {
$element = $this->parent;
- while (null !== $element) {
+ while ($element instanceof self) {
/** @var AbstractStructuralDBElement $element */
$element = $element->parent;
++$this->level;
@@ -233,16 +261,18 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
*
* @return string the full path (incl. the name of this element), delimited by $delimiter
*/
+ #[Groups(['api:basic:read'])]
+ #[SerializedName('full_path')]
public function getFullPath(string $delimiter = self::PATH_DELIMITER_ARROW): string
{
- if (empty($this->full_path_strings)) {
+ if ($this->full_path_strings === []) {
$this->full_path_strings = [];
$this->full_path_strings[] = $this->getName();
$element = $this;
$overflow = 20; //We only allow 20 levels depth
- while (null !== $element->parent && $overflow >= 0) {
+ while ($element->parent instanceof self && $overflow >= 0) {
$element = $element->parent;
$this->full_path_strings[] = $element->getName();
//Decrement to prevent mem overflow.
@@ -282,6 +312,13 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
*/
public function getSubelements(): iterable
{
+ //If the parent is equal to this object, we would get an endless loop, so just return an empty array
+ //This is just a workaround, as validator should prevent this behaviour, before it gets written to the database
+ if ($this->parent === $this) {
+ return new ArrayCollection();
+ }
+
+ //@phpstan-ignore-next-line
return $this->children ?? new ArrayCollection();
}
@@ -309,9 +346,8 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
/**
* Sets the new parent object.
*
- * @param AbstractStructuralDBElement|null $new_parent The new parent object
- *
- * @return AbstractStructuralDBElement
+ * @param static|null $new_parent The new parent object
+ * @return $this
*/
public function setParent(?self $new_parent): self
{
@@ -323,7 +359,7 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
$this->parent = $new_parent;
//Add this element as child to the new parent
- if (null !== $new_parent) {
+ if ($new_parent instanceof self) {
$new_parent->getChildren()->add($this);
}
@@ -333,11 +369,11 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
/**
* Set the comment.
*
- * @param string|null $new_comment the new comment
+ * @param string $new_comment the new comment
*
- * @return AbstractStructuralDBElement
+ * @return $this
*/
- public function setComment(?string $new_comment): self
+ public function setComment(string $new_comment): self
{
$this->comment = $new_comment;
@@ -386,4 +422,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) && !str_ends_with($new_value, ',')) {
+ $new_value .= ',';
+ }
+
+ $this->alternative_names = $new_value;
+
+ return $this;
+ }
}
diff --git a/src/Entity/Base/MasterAttachmentTrait.php b/src/Entity/Base/MasterAttachmentTrait.php
index 42461f65..723bab07 100644
--- a/src/Entity/Base/MasterAttachmentTrait.php
+++ b/src/Entity/Base/MasterAttachmentTrait.php
@@ -23,20 +23,21 @@ declare(strict_types=1);
namespace App\Entity\Base;
use App\Entity\Attachments\Attachment;
-use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
- * A entity with this class has a master attachment, which is used as a preview image for this object.
+ * An entity with this class has a master attachment, which is used as a preview image for this object.
*/
trait MasterAttachmentTrait
{
/**
- * @var Attachment
- * @ORM\ManyToOne(targetEntity="App\Entity\Attachments\Attachment")
- * @ORM\JoinColumn(name="id_preview_attachement", referencedColumnName="id")
- * @Assert\Expression("value == null or value.isPicture()", message="part.master_attachment.must_be_picture")
+ * @var Attachment|null
+ * Mapping is done in the subclasses (e.g. Part), like with the attachments.
+ * If this is done here (which is possible in theory), the attachment is not lazy loaded anymore, which causes unnecessary overhead.
+ *
+ * !!! If you change this name, you have to change it in the fetchHint in the AttachmentContainingDBElementRepository (getElementsAndPreviewAttachmentByIDs()) too !!!
*/
+ #[Assert\Expression('value == null or value.isPicture()', message: 'part.master_attachment.must_be_picture')]
protected ?Attachment $master_picture_attachment = null;
/**
diff --git a/src/Entity/Base/PartsContainingRepositoryInterface.php b/src/Entity/Base/PartsContainingRepositoryInterface.php
index 16932677..89e7e5f6 100644
--- a/src/Entity/Base/PartsContainingRepositoryInterface.php
+++ b/src/Entity/Base/PartsContainingRepositoryInterface.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Entity\Base;
use App\Entity\Parts\Part;
@@ -28,11 +30,11 @@ interface PartsContainingRepositoryInterface
* Returns all parts associated with this element.
*
* @param object $element the element for which the parts should be determined
- * @param array $order_by The order of the parts. Format ['name' => 'ASC']
+ * @param string $nameOrderDirection the direction in which the parts should be ordered by name, either ASC or DESC
*
* @return Part[]
*/
- public function getParts(object $element, array $order_by = ['name' => 'ASC']): array;
+ public function getParts(object $element, string $nameOrderDirection = "ASC"): array;
/**
* Gets the count of the parts associated with this element.
diff --git a/src/Entity/Base/TimestampTrait.php b/src/Entity/Base/TimestampTrait.php
index 3caf3358..77506b18 100644
--- a/src/Entity/Base/TimestampTrait.php
+++ b/src/Entity/Base/TimestampTrait.php
@@ -22,6 +22,8 @@ declare(strict_types=1);
namespace App\Entity\Base;
+use ApiPlatform\Metadata\ApiProperty;
+use Doctrine\DBAL\Types\Types;
use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
@@ -32,26 +34,28 @@ use Symfony\Component\Serializer\Annotation\Groups;
trait TimestampTrait
{
/**
- * @var DateTime|null the date when this element was modified the last time
- * @ORM\Column(type="datetime", name="last_modified", options={"default"="CURRENT_TIMESTAMP"})
- * @Groups({"extended", "full"})
+ * @var \DateTimeImmutable|null the date when this element was modified the last time
*/
- protected ?DateTime $lastModified = null;
+ #[Groups(['extended', 'full'])]
+ #[ApiProperty(writable: false)]
+ #[ORM\Column(name: 'last_modified', type: Types::DATETIME_IMMUTABLE, options: ['default' => 'CURRENT_TIMESTAMP'])]
+ protected ?\DateTimeImmutable $lastModified = null;
/**
- * @var DateTime|null the date when this element was created
- * @ORM\Column(type="datetime", name="datetime_added", options={"default"="CURRENT_TIMESTAMP"})
- * @Groups({"extended", "full"})
+ * @var \DateTimeImmutable|null the date when this element was created
*/
- protected ?DateTime $addedDate = null;
+ #[Groups(['extended', 'full'])]
+ #[ApiProperty(writable: false)]
+ #[ORM\Column(name: 'datetime_added', type: Types::DATETIME_IMMUTABLE, options: ['default' => 'CURRENT_TIMESTAMP'])]
+ protected ?\DateTimeImmutable $addedDate = null;
/**
* Returns the last time when the element was modified.
* Returns null if the element was not yet saved to DB yet.
*
- * @return DateTime|null the time of the last edit
+ * @return \DateTimeImmutable|null the time of the last edit
*/
- public function getLastModified(): ?DateTime
+ public function getLastModified(): ?\DateTimeImmutable
{
return $this->lastModified;
}
@@ -60,24 +64,23 @@ trait TimestampTrait
* Returns the date/time when the element was created.
* Returns null if the element was not yet saved to DB yet.
*
- * @return DateTime|null the creation time of the part
+ * @return \DateTimeImmutable|null the creation time of the part
*/
- public function getAddedDate(): ?DateTime
+ public function getAddedDate(): ?\DateTimeImmutable
{
return $this->addedDate;
}
/**
* Helper for updating the timestamp. It is automatically called by doctrine before persisting.
- *
- * @ORM\PrePersist
- * @ORM\PreUpdate
*/
+ #[ORM\PrePersist]
+ #[ORM\PreUpdate]
public function updateTimestamps(): void
{
- $this->lastModified = new DateTime('now');
+ $this->lastModified = new \DateTimeImmutable('now');
if (null === $this->addedDate) {
- $this->addedDate = new DateTime('now');
+ $this->addedDate = new \DateTimeImmutable('now');
}
}
}
diff --git a/src/Entity/Contracts/LogWithEventUndoInterface.php b/src/Entity/Contracts/LogWithEventUndoInterface.php
index fecc6eaa..1e8db0a1 100644
--- a/src/Entity/Contracts/LogWithEventUndoInterface.php
+++ b/src/Entity/Contracts/LogWithEventUndoInterface.php
@@ -42,6 +42,7 @@ declare(strict_types=1);
namespace App\Entity\Contracts;
use App\Entity\LogSystem\AbstractLogEntry;
+use App\Services\LogSystem\EventUndoMode;
interface LogWithEventUndoInterface
{
@@ -60,12 +61,12 @@ interface LogWithEventUndoInterface
*
* @return $this
*/
- public function setUndoneEvent(AbstractLogEntry $event, string $mode = 'undo'): self;
+ public function setUndoneEvent(AbstractLogEntry $event, EventUndoMode $mode = EventUndoMode::UNDO): self;
/**
* Returns the mode how the event was undone:
* "undo" = Only a single event was applied to element
* "revert" = Element was reverted to the state it was to the timestamp of the log.
*/
- public function getUndoMode(): string;
+ public function getUndoMode(): EventUndoMode;
}
diff --git a/src/Entity/Contracts/LogWithNewDataInterface.php b/src/Entity/Contracts/LogWithNewDataInterface.php
new file mode 100644
index 00000000..c4128cb7
--- /dev/null
+++ b/src/Entity/Contracts/LogWithNewDataInterface.php
@@ -0,0 +1,43 @@
+.
+ */
+namespace App\Entity\Contracts;
+
+interface LogWithNewDataInterface
+{
+ /**
+ * Checks if this entry has information about the new data.
+ * @return bool
+ */
+ public function hasNewDataInformation(): bool;
+
+ /**
+ * Returns the new data for this entry.
+ */
+ public function getNewData(): array;
+
+ /**
+ * Sets the new data for this entry.
+ * @return $this
+ */
+ public function setNewData(array $new_data): self;
+}
diff --git a/src/Entity/Contracts/TimeStampableInterface.php b/src/Entity/Contracts/TimeStampableInterface.php
index e198ae7c..c99c0a1c 100644
--- a/src/Entity/Contracts/TimeStampableInterface.php
+++ b/src/Entity/Contracts/TimeStampableInterface.php
@@ -22,23 +22,21 @@ declare(strict_types=1);
namespace App\Entity\Contracts;
-use DateTime;
-
interface TimeStampableInterface
{
/**
* Returns the last time when the element was modified.
* Returns null if the element was not yet saved to DB yet.
*
- * @return DateTime|null the time of the last edit
+ * @return \DateTimeInterface|null the time of the last edit
*/
- public function getLastModified(): ?DateTime;
+ public function getLastModified(): ?\DateTimeInterface;
/**
* Returns the date/time when the element was created.
* Returns null if the element was not yet saved to DB yet.
*
- * @return DateTime|null the creation time of the part
+ * @return \DateTimeInterface|null the creation time of the part
*/
- public function getAddedDate(): ?DateTime;
+ public function getAddedDate(): ?\DateTimeInterface;
}
diff --git a/src/Entity/Contracts/TimeTravelInterface.php b/src/Entity/Contracts/TimeTravelInterface.php
index 6324d75f..2b0f4571 100644
--- a/src/Entity/Contracts/TimeTravelInterface.php
+++ b/src/Entity/Contracts/TimeTravelInterface.php
@@ -22,16 +22,14 @@ declare(strict_types=1);
namespace App\Entity\Contracts;
-use DateTime;
-
interface TimeTravelInterface
{
/**
- * Checks if this entry has informations which data has changed.
+ * Checks if this entry has information which data has changed.
*
- * @return bool true if this entry has informations about the changed data
+ * @return bool true if this entry has information about the changed data
*/
- public function hasOldDataInformations(): bool;
+ public function hasOldDataInformation(): bool;
/**
* Returns the data the entity had before this log entry.
@@ -39,7 +37,7 @@ interface TimeTravelInterface
public function getOldData(): array;
/**
- * Returns the the timestamp associated with this change.
+ * Returns the timestamp associated with this change.
*/
- public function getTimestamp(): DateTime;
+ public function getTimestamp(): \DateTimeInterface;
}
diff --git a/src/Entity/EDA/EDACategoryInfo.php b/src/Entity/EDA/EDACategoryInfo.php
new file mode 100644
index 00000000..0163dfb3
--- /dev/null
+++ b/src/Entity/EDA/EDACategoryInfo.php
@@ -0,0 +1,135 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Entity\EDA;
+
+use Doctrine\DBAL\Types\Types;
+use Doctrine\ORM\Mapping\Column;
+use Doctrine\ORM\Mapping\Embeddable;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Validator\Constraints\Length;
+
+#[Embeddable]
+class EDACategoryInfo
+{
+ /**
+ * @var string|null The reference prefix of the Part in the schematic. E.g. "R" for resistors, or "C" for capacitors.
+ */
+ #[Column(type: Types::STRING, nullable: true)]
+ #[Groups(['full', 'category:read', 'category:write', 'import'])]
+ #[Length(max: 255)]
+ private ?string $reference_prefix = null;
+
+ /** @var bool|null Visibility of this part to EDA software in trinary logic. True=Visible, False=Invisible, Null=Auto */
+ #[Column(name: 'invisible', type: Types::BOOLEAN, nullable: true)] //TODO: Rename column to visibility
+ #[Groups(['full', 'category:read', 'category:write', 'import'])]
+ private ?bool $visibility = null;
+
+ /** @var bool|null If this is set to true, then this part will be excluded from the BOM */
+ #[Column(type: Types::BOOLEAN, nullable: true)]
+ #[Groups(['full', 'category:read', 'category:write', 'import'])]
+ private ?bool $exclude_from_bom = null;
+
+ /** @var bool|null If this is set to true, then this part will be excluded from the board/the PCB */
+ #[Column(type: Types::BOOLEAN, nullable: true)]
+ #[Groups(['full', 'category:read', 'category:write', 'import'])]
+ private ?bool $exclude_from_board = null;
+
+ /** @var bool|null If this is set to true, then this part will be excluded in the simulation */
+ #[Column(type: Types::BOOLEAN, nullable: true)]
+ #[Groups(['full', 'category:read', 'category:write', 'import'])]
+ private ?bool $exclude_from_sim = true;
+
+ /** @var string|null The KiCAD schematic symbol, which should be used (the path to the library) */
+ #[Column(type: Types::STRING, nullable: true)]
+ #[Groups(['full', 'category:read', 'category:write', 'import'])]
+ #[Length(max: 255)]
+ private ?string $kicad_symbol = null;
+
+ public function getReferencePrefix(): ?string
+ {
+ return $this->reference_prefix;
+ }
+
+ public function setReferencePrefix(?string $reference_prefix): EDACategoryInfo
+ {
+ $this->reference_prefix = $reference_prefix;
+ return $this;
+ }
+
+ public function getVisibility(): ?bool
+ {
+ return $this->visibility;
+ }
+
+ public function setVisibility(?bool $visibility): EDACategoryInfo
+ {
+ $this->visibility = $visibility;
+ return $this;
+ }
+
+ public function getExcludeFromBom(): ?bool
+ {
+ return $this->exclude_from_bom;
+ }
+
+ public function setExcludeFromBom(?bool $exclude_from_bom): EDACategoryInfo
+ {
+ $this->exclude_from_bom = $exclude_from_bom;
+ return $this;
+ }
+
+ public function getExcludeFromBoard(): ?bool
+ {
+ return $this->exclude_from_board;
+ }
+
+ public function setExcludeFromBoard(?bool $exclude_from_board): EDACategoryInfo
+ {
+ $this->exclude_from_board = $exclude_from_board;
+ return $this;
+ }
+
+ public function getExcludeFromSim(): ?bool
+ {
+ return $this->exclude_from_sim;
+ }
+
+ public function setExcludeFromSim(?bool $exclude_from_sim): EDACategoryInfo
+ {
+ $this->exclude_from_sim = $exclude_from_sim;
+ return $this;
+ }
+
+ public function getKicadSymbol(): ?string
+ {
+ return $this->kicad_symbol;
+ }
+
+ public function setKicadSymbol(?string $kicad_symbol): EDACategoryInfo
+ {
+ $this->kicad_symbol = $kicad_symbol;
+ return $this;
+ }
+
+}
\ No newline at end of file
diff --git a/src/Entity/EDA/EDAFootprintInfo.php b/src/Entity/EDA/EDAFootprintInfo.php
new file mode 100644
index 00000000..9c5ef1c1
--- /dev/null
+++ b/src/Entity/EDA/EDAFootprintInfo.php
@@ -0,0 +1,51 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Entity\EDA;
+
+use Doctrine\DBAL\Types\Types;
+use Doctrine\ORM\Mapping\Column;
+use Doctrine\ORM\Mapping\Embeddable;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Validator\Constraints\Length;
+
+#[Embeddable]
+class EDAFootprintInfo
+{
+ /** @var string|null The KiCAD footprint, which should be used (the path to the library) */
+ #[Column(type: Types::STRING, nullable: true)]
+ #[Groups(['full', 'footprint:read', 'footprint:write', 'import'])]
+ #[Length(max: 255)]
+ private ?string $kicad_footprint = null;
+
+ public function getKicadFootprint(): ?string
+ {
+ return $this->kicad_footprint;
+ }
+
+ public function setKicadFootprint(?string $kicad_footprint): EDAFootprintInfo
+ {
+ $this->kicad_footprint = $kicad_footprint;
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/src/Entity/EDA/EDAPartInfo.php b/src/Entity/EDA/EDAPartInfo.php
new file mode 100644
index 00000000..b4fc3588
--- /dev/null
+++ b/src/Entity/EDA/EDAPartInfo.php
@@ -0,0 +1,175 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Entity\EDA;
+
+use Doctrine\DBAL\Types\Types;
+use Doctrine\ORM\Mapping\Column;
+use Doctrine\ORM\Mapping\Embeddable;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Validator\Constraints\Length;
+
+#[Embeddable]
+class EDAPartInfo
+{
+ /**
+ * @var string|null The reference prefix of the Part in the schematic. E.g. "R" for resistors, or "C" for capacitors.
+ */
+ #[Column(type: Types::STRING, nullable: true)]
+ #[Groups(['full', 'eda_info:read', 'eda_info:write', 'import'])]
+ #[Length(max: 255)]
+ private ?string $reference_prefix = null;
+
+ /** @var string|null The value, which should be shown together with the part (e.g. 470 for a 470 Ohm resistor) */
+ #[Column(type: Types::STRING, nullable: true)]
+ #[Groups(['full', 'eda_info:read', 'eda_info:write', 'import'])]
+ #[Length(max: 255)]
+ private ?string $value = null;
+
+ /** @var bool|null Visibility of this part to EDA software in trinary logic. True=Visible, False=Invisible, Null=Auto */
+ #[Column(name: 'invisible', type: Types::BOOLEAN, nullable: true)] //TODO: Rename column to visibility
+ #[Groups(['full', 'eda_info:read', 'eda_info:write', 'import'])]
+ private ?bool $visibility = null;
+
+ /** @var bool|null If this is set to true, then this part will be excluded from the BOM */
+ #[Column(type: Types::BOOLEAN, nullable: true)]
+ #[Groups(['full', 'eda_info:read', 'eda_info:write', 'import'])]
+ private ?bool $exclude_from_bom = null;
+
+ /** @var bool|null If this is set to true, then this part will be excluded from the board/the PCB */
+ #[Column(type: Types::BOOLEAN, nullable: true)]
+ #[Groups(['full', 'eda_info:read', 'eda_info:write', 'import'])]
+ private ?bool $exclude_from_board = null;
+
+ /** @var bool|null If this is set to true, then this part will be excluded in the simulation */
+ #[Column(type: Types::BOOLEAN, nullable: true)]
+ #[Groups(['full', 'eda_info:read', 'eda_info:write', 'import'])]
+ private ?bool $exclude_from_sim = null;
+
+ /** @var string|null The KiCAD schematic symbol, which should be used (the path to the library) */
+ #[Column(type: Types::STRING, nullable: true)]
+ #[Groups(['full', 'eda_info:read', 'eda_info:write', 'import'])]
+ #[Length(max: 255)]
+ private ?string $kicad_symbol = null;
+
+ /** @var string|null The KiCAD footprint, which should be used (the path to the library) */
+ #[Column(type: Types::STRING, nullable: true)]
+ #[Groups(['full', 'eda_info:read', 'eda_info:write', 'import'])]
+ #[Length(max: 255)]
+ private ?string $kicad_footprint = null;
+
+ public function __construct()
+ {
+
+ }
+
+ public function getReferencePrefix(): ?string
+ {
+ return $this->reference_prefix;
+ }
+
+ public function setReferencePrefix(?string $reference_prefix): EDAPartInfo
+ {
+ $this->reference_prefix = $reference_prefix;
+ return $this;
+ }
+
+ public function getValue(): ?string
+ {
+ return $this->value;
+ }
+
+ public function setValue(?string $value): EDAPartInfo
+ {
+ $this->value = $value;
+ return $this;
+ }
+
+ public function getVisibility(): ?bool
+ {
+ return $this->visibility;
+ }
+
+ public function setVisibility(?bool $visibility): EDAPartInfo
+ {
+ $this->visibility = $visibility;
+ return $this;
+ }
+
+ public function getExcludeFromBom(): ?bool
+ {
+ return $this->exclude_from_bom;
+ }
+
+ public function setExcludeFromBom(?bool $exclude_from_bom): EDAPartInfo
+ {
+ $this->exclude_from_bom = $exclude_from_bom;
+ return $this;
+ }
+
+ public function getExcludeFromBoard(): ?bool
+ {
+ return $this->exclude_from_board;
+ }
+
+ public function setExcludeFromBoard(?bool $exclude_from_board): EDAPartInfo
+ {
+ $this->exclude_from_board = $exclude_from_board;
+ return $this;
+ }
+
+ public function getExcludeFromSim(): ?bool
+ {
+ return $this->exclude_from_sim;
+ }
+
+ public function setExcludeFromSim(?bool $exclude_from_sim): EDAPartInfo
+ {
+ $this->exclude_from_sim = $exclude_from_sim;
+ return $this;
+ }
+
+ public function getKicadSymbol(): ?string
+ {
+ return $this->kicad_symbol;
+ }
+
+ public function setKicadSymbol(?string $kicad_symbol): EDAPartInfo
+ {
+ $this->kicad_symbol = $kicad_symbol;
+ return $this;
+ }
+
+ public function getKicadFootprint(): ?string
+ {
+ return $this->kicad_footprint;
+ }
+
+ public function setKicadFootprint(?string $kicad_footprint): EDAPartInfo
+ {
+ $this->kicad_footprint = $kicad_footprint;
+ return $this;
+ }
+
+
+}
\ No newline at end of file
diff --git a/src/Entity/LabelSystem/BarcodeType.php b/src/Entity/LabelSystem/BarcodeType.php
new file mode 100644
index 00000000..daf7d401
--- /dev/null
+++ b/src/Entity/LabelSystem/BarcodeType.php
@@ -0,0 +1,66 @@
+.
+ */
+namespace App\Entity\LabelSystem;
+
+enum BarcodeType: string
+{
+ case NONE = 'none';
+ case QR = 'qr';
+ case CODE39 = 'code39';
+ case DATAMATRIX = 'datamatrix';
+ case CODE93 = 'code93';
+ case CODE128 = 'code128';
+
+ /**
+ * Returns true if the barcode is none. (Useful for twig templates)
+ * @return bool
+ */
+ public function isNone(): bool
+ {
+ return $this === self::NONE;
+ }
+
+ /**
+ * Returns true if the barcode is a 1D barcode (Code39, etc.).
+ * @return bool
+ */
+ public function is1D(): bool
+ {
+ return match ($this) {
+ self::CODE39, self::CODE93, self::CODE128 => true,
+ default => false,
+ };
+ }
+
+ /**
+ * Returns true if the barcode is a 2D barcode (QR code, datamatrix).
+ * @return bool
+ */
+ public function is2D(): bool
+ {
+ return match ($this) {
+ self::QR, self::DATAMATRIX => true,
+ default => false,
+ };
+ }
+}
diff --git a/src/Entity/LabelSystem/LabelOptions.php b/src/Entity/LabelSystem/LabelOptions.php
index f3f448ad..ee1a5414 100644
--- a/src/Entity/LabelSystem/LabelOptions.php
+++ b/src/Entity/LabelSystem/LabelOptions.php
@@ -41,71 +41,66 @@ declare(strict_types=1);
namespace App\Entity\LabelSystem;
+use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
-/**
- * @ORM\Embeddable()
- */
+#[ORM\Embeddable]
class LabelOptions
{
- public const BARCODE_TYPES = ['none', /*'ean8',*/ 'qr', 'code39', 'datamatrix', 'code93', 'code128'];
- public const SUPPORTED_ELEMENTS = ['part', 'part_lot', 'storelocation'];
- public const PICTURE_TYPES = ['none', 'element_picture', 'main_attachment'];
-
- public const LINES_MODES = ['html', 'twig'];
-
/**
* @var float The page size of the label in mm
- * @Assert\Positive()
- * @ORM\Column(type="float")
*/
+ #[Assert\Positive]
+ #[ORM\Column(type: Types::FLOAT)]
+ #[Groups(["extended", "full", "import"])]
protected float $width = 50.0;
/**
* @var float The page size of the label in mm
- * @Assert\Positive()
- * @ORM\Column(type="float")
*/
+ #[Assert\Positive]
+ #[ORM\Column(type: Types::FLOAT)]
+ #[Groups(["extended", "full", "import"])]
protected float $height = 30.0;
/**
- * @var string The type of the barcode that should be used in the label (e.g. 'qr')
- * @Assert\Choice(choices=LabelOptions::BARCODE_TYPES)
- * @ORM\Column(type="string")
+ * @var BarcodeType The type of the barcode that should be used in the label (e.g. 'qr')
*/
- protected string $barcode_type = 'none';
+ #[ORM\Column(type: Types::STRING, enumType: BarcodeType::class)]
+ #[Groups(["extended", "full", "import"])]
+ protected BarcodeType $barcode_type = BarcodeType::NONE;
/**
- * @var string What image should be shown along the
- * @Assert\Choice(choices=LabelOptions::PICTURE_TYPES)
- * @ORM\Column(type="string")
+ * @var LabelPictureType What image should be shown along the label
*/
- protected string $picture_type = 'none';
+ #[ORM\Column(type: Types::STRING, enumType: LabelPictureType::class)]
+ #[Groups(["extended", "full", "import"])]
+ protected LabelPictureType $picture_type = LabelPictureType::NONE;
- /**
- * @var string
- * @Assert\Choice(choices=LabelOptions::SUPPORTED_ELEMENTS)
- * @ORM\Column(type="string")
- */
- protected string $supported_element = 'part';
+ #[ORM\Column(type: Types::STRING, enumType: LabelSupportedElement::class)]
+ #[Groups(["extended", "full", "import"])]
+ protected LabelSupportedElement $supported_element = LabelSupportedElement::PART;
/**
* @var string any additional CSS for the label
- * @ORM\Column(type="text")
*/
+ #[ORM\Column(type: Types::TEXT)]
+ #[Groups([ "full", "import"])]
protected string $additional_css = '';
- /** @var string The mode that will be used to interpret the lines
- * @Assert\Choice(choices=LabelOptions::LINES_MODES)
- * @ORM\Column(type="string")
+ /** @var LabelProcessMode The mode that will be used to interpret the lines
*/
- protected string $lines_mode = 'html';
+ #[ORM\Column(name: 'lines_mode', type: Types::STRING, enumType: LabelProcessMode::class)]
+ #[Groups(["extended", "full", "import"])]
+ protected LabelProcessMode $process_mode = LabelProcessMode::PLACEHOLDER;
/**
* @var string
- * @ORM\Column(type="text")
*/
+ #[ORM\Column(type: Types::TEXT)]
+ #[Groups(["extended", "full", "import"])]
protected string $lines = '';
public function getWidth(): float
@@ -113,9 +108,6 @@ class LabelOptions
return $this->width;
}
- /**
- * @return LabelOptions
- */
public function setWidth(float $width): self
{
$this->width = $width;
@@ -128,9 +120,6 @@ class LabelOptions
return $this->height;
}
- /**
- * @return LabelOptions
- */
public function setHeight(float $height): self
{
$this->height = $height;
@@ -138,45 +127,36 @@ class LabelOptions
return $this;
}
- public function getBarcodeType(): string
+ public function getBarcodeType(): BarcodeType
{
return $this->barcode_type;
}
- /**
- * @return LabelOptions
- */
- public function setBarcodeType(string $barcode_type): self
+ public function setBarcodeType(BarcodeType $barcode_type): self
{
$this->barcode_type = $barcode_type;
return $this;
}
- public function getPictureType(): string
+ public function getPictureType(): LabelPictureType
{
return $this->picture_type;
}
- /**
- * @return LabelOptions
- */
- public function setPictureType(string $picture_type): self
+ public function setPictureType(LabelPictureType $picture_type): self
{
$this->picture_type = $picture_type;
return $this;
}
- public function getSupportedElement(): string
+ public function getSupportedElement(): LabelSupportedElement
{
return $this->supported_element;
}
- /**
- * @return LabelOptions
- */
- public function setSupportedElement(string $supported_element): self
+ public function setSupportedElement(LabelSupportedElement $supported_element): self
{
$this->supported_element = $supported_element;
@@ -188,9 +168,6 @@ class LabelOptions
return $this->lines;
}
- /**
- * @return LabelOptions
- */
public function setLines(string $lines): self
{
$this->lines = $lines;
@@ -206,9 +183,6 @@ class LabelOptions
return $this->additional_css;
}
- /**
- * @return LabelOptions
- */
public function setAdditionalCss(string $additional_css): self
{
$this->additional_css = $additional_css;
@@ -216,17 +190,14 @@ class LabelOptions
return $this;
}
- public function getLinesMode(): string
+ public function getProcessMode(): LabelProcessMode
{
- return $this->lines_mode;
+ return $this->process_mode;
}
- /**
- * @return LabelOptions
- */
- public function setLinesMode(string $lines_mode): self
+ public function setProcessMode(LabelProcessMode $process_mode): self
{
- $this->lines_mode = $lines_mode;
+ $this->process_mode = $process_mode;
return $this;
}
diff --git a/src/Entity/LabelSystem/LabelPictureType.php b/src/Entity/LabelSystem/LabelPictureType.php
new file mode 100644
index 00000000..c1f90fe2
--- /dev/null
+++ b/src/Entity/LabelSystem/LabelPictureType.php
@@ -0,0 +1,39 @@
+.
+ */
+namespace App\Entity\LabelSystem;
+
+enum LabelPictureType: string
+{
+ /**
+ * Show no picture on the label
+ */
+ case NONE = 'none';
+ /**
+ * Show the preview picture of the element on the label
+ */
+ case ELEMENT_PICTURE = 'element_picture';
+ /**
+ * Show the main attachment of the element on the label
+ */
+ case MAIN_ATTACHMENT = 'main_attachment';
+}
diff --git a/assets/css/app/darkmode.css b/src/Entity/LabelSystem/LabelProcessMode.php
similarity index 68%
rename from assets/css/app/darkmode.css
rename to src/Entity/LabelSystem/LabelProcessMode.php
index 4368ed64..d5967b49 100644
--- a/assets/css/app/darkmode.css
+++ b/src/Entity/LabelSystem/LabelProcessMode.php
@@ -1,7 +1,11 @@
+.
*/
+namespace App\Entity\LabelSystem;
-.darkmode-layer {
- z-index: 2020;
-}
-
-/** If darkmode is enabled revert the blening for images and videos, as these should be shown not inverted */
-.darkmode--activated img,
-.darkmode--activated video {
- mix-blend-mode: difference;
-}
-
-.darkmode--activated .hoverpic:hover {
- background: black;
+enum LabelProcessMode: string
+{
+ /** Use placeholders like [[PLACEHOLDER]] which gets replaced with content */
+ case PLACEHOLDER = 'html';
+ /** Interpret the given lines as twig template */
+ case TWIG = 'twig';
}
diff --git a/src/Entity/LabelSystem/LabelProfile.php b/src/Entity/LabelSystem/LabelProfile.php
index 46c478ee..d3616c34 100644
--- a/src/Entity/LabelSystem/LabelProfile.php
+++ b/src/Entity/LabelSystem/LabelProfile.php
@@ -41,49 +41,64 @@ declare(strict_types=1);
namespace App\Entity\LabelSystem;
+use Doctrine\Common\Collections\Criteria;
+use App\Entity\Attachments\Attachment;
+use App\Repository\LabelProfileRepository;
+use App\EntityListeners\TreeCacheInvalidationListener;
+use Doctrine\DBAL\Types\Types;
+use Doctrine\Common\Collections\ArrayCollection;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\LabelAttachment;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
- * @ORM\Entity(repositoryClass="App\Repository\LabelProfileRepository")
- * @ORM\Table(name="label_profiles")
- * @ORM\EntityListeners({"App\EntityListeners\TreeCacheInvalidationListener"})
- * @UniqueEntity({"name", "options.supported_element"})
+ * @extends AttachmentContainingDBElement
*/
+#[UniqueEntity(['name', 'options.supported_element'])]
+#[ORM\Entity(repositoryClass: LabelProfileRepository::class)]
+#[ORM\EntityListeners([TreeCacheInvalidationListener::class])]
+#[ORM\Table(name: 'label_profiles')]
class LabelProfile extends AttachmentContainingDBElement
{
/**
* @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Attachments\LabelAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"name" = "ASC"})
*/
- protected $attachments;
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: LabelAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ protected Collection $attachments;
+
+ #[ORM\ManyToOne(targetEntity: LabelAttachment::class)]
+ #[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
+ protected ?Attachment $master_picture_attachment = null;
/**
* @var LabelOptions
- * @ORM\Embedded(class="LabelOptions")
- * @Assert\Valid()
*/
+ #[Assert\Valid]
+ #[ORM\Embedded(class: 'LabelOptions')]
+ #[Groups(["extended", "full", "import"])]
protected LabelOptions $options;
/**
* @var string The comment info for this element
- * @ORM\Column(type="text")
*/
+ #[ORM\Column(type: Types::TEXT)]
protected string $comment = '';
/**
* @var bool determines, if this label profile should be shown in the dropdown quick menu
- * @ORM\Column(type="boolean")
*/
+ #[ORM\Column(type: Types::BOOLEAN)]
+ #[Groups(["extended", "full", "import"])]
protected bool $show_in_dropdown = true;
public function __construct()
{
+ $this->attachments = new ArrayCollection();
parent::__construct();
$this->options = new LabelOptions();
}
@@ -127,8 +142,6 @@ class LabelProfile extends AttachmentContainingDBElement
/**
* Sets the show in dropdown menu.
- *
- * @return LabelProfile
*/
public function setShowInDropdown(bool $show_in_dropdown): self
{
diff --git a/src/Entity/LabelSystem/LabelSupportedElement.php b/src/Entity/LabelSystem/LabelSupportedElement.php
new file mode 100644
index 00000000..7649e586
--- /dev/null
+++ b/src/Entity/LabelSystem/LabelSupportedElement.php
@@ -0,0 +1,49 @@
+.
+ */
+namespace App\Entity\LabelSystem;
+
+use App\Entity\Base\AbstractDBElement;
+use App\Entity\Base\AbstractNamedDBElement;
+use App\Entity\Parts\Part;
+use App\Entity\Parts\PartLot;
+use App\Entity\Parts\StorageLocation;
+
+enum LabelSupportedElement: string
+{
+ case PART = 'part';
+ case PART_LOT = 'part_lot';
+ case STORELOCATION = 'storelocation';
+
+ /**
+ * Returns the entity class for the given element type
+ * @return class-string
+ */
+ public function getEntityClass(): string
+ {
+ return match ($this) {
+ self::PART => Part::class,
+ self::PART_LOT => PartLot::class,
+ self::STORELOCATION => StorageLocation::class,
+ };
+ }
+}
diff --git a/src/Entity/LogSystem/AbstractLogEntry.php b/src/Entity/LogSystem/AbstractLogEntry.php
index e2dca513..aa795613 100644
--- a/src/Entity/LogSystem/AbstractLogEntry.php
+++ b/src/Entity/LogSystem/AbstractLogEntry.php
@@ -22,157 +22,60 @@ declare(strict_types=1);
namespace App\Entity\LogSystem;
-use App\Entity\Attachments\Attachment;
-use App\Entity\Attachments\AttachmentType;
+use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractDBElement;
-use App\Entity\ProjectSystem\Project;
-use App\Entity\ProjectSystem\ProjectBOMEntry;
-use App\Entity\LabelSystem\LabelProfile;
-use App\Entity\Parameters\AbstractParameter;
-use App\Entity\Parts\Category;
-use App\Entity\Parts\Footprint;
-use App\Entity\Parts\Manufacturer;
-use App\Entity\Parts\MeasurementUnit;
-use App\Entity\Parts\Part;
-use App\Entity\Parts\PartLot;
-use App\Entity\Parts\Storelocation;
-use App\Entity\Parts\Supplier;
-use App\Entity\PriceInformations\Currency;
-use App\Entity\PriceInformations\Orderdetail;
-use App\Entity\PriceInformations\Pricedetail;
-use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
-use DateTime;
+
use Doctrine\ORM\Mapping as ORM;
-use InvalidArgumentException;
-use Psr\Log\LogLevel;
+use App\Repository\LogEntryRepository;
/**
- * This entity describes a entry in the event log.
- *
- * @ORM\Entity(repositoryClass="App\Repository\LogEntryRepository")
- * @ORM\Table("log", indexes={
- * @ORM\Index(name="log_idx_type", columns={"type"}),
- * @ORM\Index(name="log_idx_type_target", columns={"type", "target_type", "target_id"}),
- * @ORM\Index(name="log_idx_datetime", columns={"datetime"}),
- * })
- * @ORM\InheritanceType("SINGLE_TABLE")
- * @ORM\DiscriminatorColumn(name="type", type="smallint")
- * @ORM\DiscriminatorMap({
- * 1 = "UserLoginLogEntry",
- * 2 = "UserLogoutLogEntry",
- * 3 = "UserNotAllowedLogEntry",
- * 4 = "ExceptionLogEntry",
- * 5 = "ElementDeletedLogEntry",
- * 6 = "ElementCreatedLogEntry",
- * 7 = "ElementEditedLogEntry",
- * 8 = "ConfigChangedLogEntry",
- * 9 = "LegacyInstockChangedLogEntry",
- * 10 = "DatabaseUpdatedLogEntry",
- * 11 = "CollectionElementDeleted",
- * 12 = "SecurityEventLogEntry",
- * 13 = "PartStockChangedLogEntry",
- * })
+ * This entity describes an entry in the event log.
+ * @see \App\Tests\Entity\LogSystem\AbstractLogEntryTest
*/
+#[ORM\Entity(repositoryClass: LogEntryRepository::class)]
+#[ORM\Table('log')]
+#[ORM\InheritanceType('SINGLE_TABLE')]
+#[ORM\DiscriminatorColumn(name: 'type', type: 'smallint')]
+#[ORM\DiscriminatorMap([1 => 'UserLoginLogEntry', 2 => 'UserLogoutLogEntry', 3 => 'UserNotAllowedLogEntry', 4 => 'ExceptionLogEntry', 5 => 'ElementDeletedLogEntry', 6 => 'ElementCreatedLogEntry', 7 => 'ElementEditedLogEntry', 8 => 'ConfigChangedLogEntry', 9 => 'LegacyInstockChangedLogEntry', 10 => 'DatabaseUpdatedLogEntry', 11 => 'CollectionElementDeleted', 12 => 'SecurityEventLogEntry', 13 => 'PartStockChangedLogEntry'])]
+#[ORM\Index(columns: ['type'], name: 'log_idx_type')]
+#[ORM\Index(columns: ['type', 'target_type', 'target_id'], name: 'log_idx_type_target')]
+#[ORM\Index(columns: ['datetime'], name: 'log_idx_datetime')]
abstract class AbstractLogEntry extends AbstractDBElement
{
- public const LEVEL_EMERGENCY = 0;
- public const LEVEL_ALERT = 1;
- public const LEVEL_CRITICAL = 2;
- public const LEVEL_ERROR = 3;
- public const LEVEL_WARNING = 4;
- public const LEVEL_NOTICE = 5;
- public const LEVEL_INFO = 6;
- public const LEVEL_DEBUG = 7;
-
- protected const TARGET_TYPE_NONE = 0;
- protected const TARGET_TYPE_USER = 1;
- protected const TARGET_TYPE_ATTACHEMENT = 2;
- protected const TARGET_TYPE_ATTACHEMENTTYPE = 3;
- protected const TARGET_TYPE_CATEGORY = 4;
- protected const TARGET_TYPE_DEVICE = 5;
- protected const TARGET_TYPE_DEVICEPART = 6;
- protected const TARGET_TYPE_FOOTPRINT = 7;
- protected const TARGET_TYPE_GROUP = 8;
- protected const TARGET_TYPE_MANUFACTURER = 9;
- protected const TARGET_TYPE_PART = 10;
- protected const TARGET_TYPE_STORELOCATION = 11;
- protected const TARGET_TYPE_SUPPLIER = 12;
- protected const TARGET_TYPE_PARTLOT = 13;
- protected const TARGET_TYPE_CURRENCY = 14;
- protected const TARGET_TYPE_ORDERDETAIL = 15;
- protected const TARGET_TYPE_PRICEDETAIL = 16;
- protected const TARGET_TYPE_MEASUREMENTUNIT = 17;
- protected const TARGET_TYPE_PARAMETER = 18;
- protected const TARGET_TYPE_LABEL_PROFILE = 19;
-
- /**
- * @var array This const is used to convert the numeric level to a PSR-3 compatible log level
- */
- protected const LEVEL_ID_TO_STRING = [
- self::LEVEL_EMERGENCY => LogLevel::EMERGENCY,
- self::LEVEL_ALERT => LogLevel::ALERT,
- self::LEVEL_CRITICAL => LogLevel::CRITICAL,
- self::LEVEL_ERROR => LogLevel::ERROR,
- self::LEVEL_WARNING => LogLevel::WARNING,
- self::LEVEL_NOTICE => LogLevel::NOTICE,
- self::LEVEL_INFO => LogLevel::INFO,
- self::LEVEL_DEBUG => LogLevel::DEBUG,
- ];
-
- protected const TARGET_CLASS_MAPPING = [
- self::TARGET_TYPE_USER => User::class,
- self::TARGET_TYPE_ATTACHEMENT => Attachment::class,
- self::TARGET_TYPE_ATTACHEMENTTYPE => AttachmentType::class,
- self::TARGET_TYPE_CATEGORY => Category::class,
- self::TARGET_TYPE_DEVICE => Project::class,
- self::TARGET_TYPE_DEVICEPART => ProjectBOMEntry::class,
- self::TARGET_TYPE_FOOTPRINT => Footprint::class,
- self::TARGET_TYPE_GROUP => Group::class,
- self::TARGET_TYPE_MANUFACTURER => Manufacturer::class,
- self::TARGET_TYPE_PART => Part::class,
- self::TARGET_TYPE_STORELOCATION => Storelocation::class,
- self::TARGET_TYPE_SUPPLIER => Supplier::class,
- self::TARGET_TYPE_PARTLOT => PartLot::class,
- self::TARGET_TYPE_CURRENCY => Currency::class,
- self::TARGET_TYPE_ORDERDETAIL => Orderdetail::class,
- self::TARGET_TYPE_PRICEDETAIL => Pricedetail::class,
- self::TARGET_TYPE_MEASUREMENTUNIT => MeasurementUnit::class,
- self::TARGET_TYPE_PARAMETER => AbstractParameter::class,
- self::TARGET_TYPE_LABEL_PROFILE => LabelProfile::class,
- ];
-
- /** @var User The user which has caused this log entry
- * @ORM\ManyToOne(targetEntity="App\Entity\UserSystem\User", fetch="EAGER")
- * @ORM\JoinColumn(name="id_user", nullable=true, onDelete="SET NULL")
+ /** @var User|null The user which has caused this log entry
*/
+ #[ORM\ManyToOne(targetEntity: User::class, fetch: 'EAGER')]
+ #[ORM\JoinColumn(name: 'id_user', onDelete: 'SET NULL')]
protected ?User $user = null;
/**
* @var string The username of the user which has caused this log entry (shown if the user is deleted)
- * @ORM\Column(type="string", nullable=false)
*/
+ #[ORM\Column(type: Types::STRING)]
protected string $username = '';
- /** @var DateTime The datetime the event associated with this log entry has occured
- * @ORM\Column(type="datetime", name="datetime")
+ /**
+ * @var \DateTimeImmutable The datetime the event associated with this log entry has occured
*/
- protected ?DateTime $timestamp = null;
+ #[ORM\Column(name: 'datetime', type: Types::DATETIME_IMMUTABLE)]
+ protected \DateTimeImmutable $timestamp;
- /** @var int The priority level of the associated level. 0 is highest, 7 lowest
- * @ORM\Column(type="integer", name="level", columnDefinition="TINYINT(4) NOT NULL")
+ /**
+ * @var LogLevel The priority level of the associated level. 0 is highest, 7 lowest
*/
- protected int $level;
+ #[ORM\Column(name: 'level', type: 'tinyint', enumType: LogLevel::class)]
+ protected LogLevel $level = LogLevel::WARNING;
/** @var int The ID of the element targeted by this event
- * @ORM\Column(name="target_id", type="integer", nullable=false)
*/
+ #[ORM\Column(name: 'target_id', type: Types::INTEGER)]
protected int $target_id = 0;
- /** @var int The Type of the targeted element
- * @ORM\Column(name="target_type", type="smallint", nullable=false)
+ /** @var LogTargetType The Type of the targeted element
*/
- protected int $target_type = 0;
+ #[ORM\Column(name: 'target_type', type: Types::SMALLINT, enumType: LogTargetType::class)]
+ protected LogTargetType $target_type = LogTargetType::NONE;
/** @var string The type of this log entry, aka the description what has happened.
* The mapping between the log entry class and the discriminator column is done by doctrine.
@@ -181,14 +84,13 @@ abstract class AbstractLogEntry extends AbstractDBElement
protected string $typeString = 'unknown';
/** @var array The extra data in raw (short form) saved in the DB
- * @ORM\Column(name="extra", type="json")
*/
- protected $extra = [];
+ #[ORM\Column(name: 'extra', type: Types::JSON)]
+ protected array $extra = [];
public function __construct()
{
- $this->timestamp = new DateTime();
- $this->level = self::LEVEL_WARNING;
+ $this->timestamp = new \DateTimeImmutable();
}
/**
@@ -216,6 +118,40 @@ abstract class AbstractLogEntry extends AbstractDBElement
return $this;
}
+ /**
+ * Returns true if this log entry was created by a CLI command, false otherwise.
+ * @return bool
+ */
+ public function isCLIEntry(): bool
+ {
+ return str_starts_with($this->username, '!!!CLI ');
+ }
+
+ /**
+ * Marks this log entry as a CLI entry, and set the username of the CLI user.
+ * This removes the association to a user object in database, as CLI users are not really related to logged in
+ * Part-DB users.
+ * @return $this
+ */
+ public function setCLIUsername(string $cli_username): self
+ {
+ $this->user = null;
+ $this->username = '!!!CLI ' . $cli_username;
+ return $this;
+ }
+
+ /**
+ * Retrieves the username of the CLI user that caused the event.
+ * @return string|null The username of the CLI user, or null if this log entry was not created by a CLI command.
+ */
+ public function getCLIUsername(): ?string
+ {
+ if ($this->isCLIEntry()) {
+ return substr($this->username, 7);
+ }
+ return null;
+ }
+
/**
* Retuns the username of the user that caused the event (useful if the user was deleted).
*
@@ -227,9 +163,9 @@ abstract class AbstractLogEntry extends AbstractDBElement
}
/**
- * Returns the timestamp when the event that caused this log entry happened.
+ * Returns the timestamp when the event that caused this log entry happened.
*/
- public function getTimestamp(): DateTime
+ public function getTimestamp(): \DateTimeImmutable
{
return $this->timestamp;
}
@@ -239,7 +175,7 @@ abstract class AbstractLogEntry extends AbstractDBElement
*
* @return $this
*/
- public function setTimestamp(DateTime $timestamp): self
+ public function setTimestamp(\DateTimeImmutable $timestamp): self
{
$this->timestamp = $timestamp;
@@ -247,16 +183,10 @@ abstract class AbstractLogEntry extends AbstractDBElement
}
/**
- * Get the priority level of this log entry. 0 is highest and 7 lowest level.
- * See LEVEL_* consts in this class for more info.
+ * Get the priority level of this log entry.
*/
- public function getLevel(): int
+ public function getLevel(): LogLevel
{
- //It is always alerting when a wrong int is saved in DB...
- if ($this->level < 0 || $this->level > 7) {
- return self::LEVEL_ALERT;
- }
-
return $this->level;
}
@@ -265,13 +195,9 @@ abstract class AbstractLogEntry extends AbstractDBElement
*
* @return $this
*/
- public function setLevel(int $level): self
+ public function setLevel(LogLevel $level): self
{
- if ($level < 0 || $this->level > 7) {
- throw new InvalidArgumentException(sprintf('$level must be between 0 and 7! %d given!', $level));
- }
$this->level = $level;
-
return $this;
}
@@ -280,7 +206,7 @@ abstract class AbstractLogEntry extends AbstractDBElement
*/
public function getLevelString(): string
{
- return self::levelIntToString($this->getLevel());
+ return $this->level->toPSR3LevelString();
}
/**
@@ -290,8 +216,7 @@ abstract class AbstractLogEntry extends AbstractDBElement
*/
public function setLevelString(string $level): self
{
- $this->setLevel(self::levelStringToInt($level));
-
+ LogLevel::fromPSR3LevelString($level);
return $this;
}
@@ -305,22 +230,27 @@ abstract class AbstractLogEntry extends AbstractDBElement
/**
* Returns the class name of the target element associated with this log entry.
- * Returns null, if this log entry is not associated with an log entry.
+ * Returns null, if this log entry is not associated with a log entry.
*
* @return string|null the class name of the target class
*/
public function getTargetClass(): ?string
{
- if (self::TARGET_TYPE_NONE === $this->target_type) {
- return null;
- }
+ return $this->target_type->toClass();
+ }
- return self::targetTypeIdToClass($this->target_type);
+ /**
+ * Returns the type of the target element associated with this log entry.
+ * @return LogTargetType
+ */
+ public function getTargetType(): LogTargetType
+ {
+ return $this->target_type;
}
/**
* Returns the ID of the target element associated with this log entry.
- * Returns null, if this log entry is not associated with an log entry.
+ * Returns null, if this log entry is not associated with a log entry.
*
* @return int|null the ID of the associated element
*/
@@ -352,14 +282,14 @@ abstract class AbstractLogEntry extends AbstractDBElement
*/
public function setTargetElement(?AbstractDBElement $element): self
{
- if (null === $element) {
+ if ($element === null) {
$this->target_id = 0;
- $this->target_type = self::TARGET_TYPE_NONE;
+ $this->target_type = LogTargetType::NONE;
return $this;
}
- $this->target_type = static::targetTypeClassToID(get_class($element));
+ $this->target_type = LogTargetType::fromElementClass($element);
$this->target_id = $element->getID();
return $this;
@@ -382,75 +312,4 @@ abstract class AbstractLogEntry extends AbstractDBElement
return $this->extra;
}
- /**
- * This function converts the internal numeric log level into an PSR3 compatible level string.
- *
- * @param int $level The numerical log level
- *
- * @return string The PSR3 compatible level string
- */
- final public static function levelIntToString(int $level): string
- {
- if (!isset(self::LEVEL_ID_TO_STRING[$level])) {
- throw new InvalidArgumentException('No level with this int is existing!');
- }
-
- return self::LEVEL_ID_TO_STRING[$level];
- }
-
- /**
- * This function converts a PSR3 compatible string to the internal numeric level string.
- *
- * @param string $level the PSR3 compatible string that should be converted
- *
- * @return int the internal int representation
- */
- final public static function levelStringToInt(string $level): int
- {
- $tmp = array_flip(self::LEVEL_ID_TO_STRING);
- if (!isset($tmp[$level])) {
- throw new InvalidArgumentException('No level with this string is existing!');
- }
-
- return $tmp[$level];
- }
-
- /**
- * Converts an target type id to an full qualified class name.
- *
- * @param int $type_id The target type ID
- */
- final public static function targetTypeIdToClass(int $type_id): string
- {
- if (!isset(self::TARGET_CLASS_MAPPING[$type_id])) {
- throw new InvalidArgumentException('No target type with this ID is existing!');
- }
-
- return self::TARGET_CLASS_MAPPING[$type_id];
- }
-
- /**
- * Convert a class name to a target type ID.
- *
- * @param string $class The name of the class (FQN) that should be converted to id
- *
- * @return int the ID of the associated target type ID
- */
- final public static function targetTypeClassToID(string $class): int
- {
- $tmp = array_flip(self::TARGET_CLASS_MAPPING);
- //Check if we can use a key directly
- if (isset($tmp[$class])) {
- return $tmp[$class];
- }
-
- //Otherwise we have to iterate over everything and check for inheritance
- foreach ($tmp as $compare_class => $class_id) {
- if (is_a($class, $compare_class, true)) {
- return $class_id;
- }
- }
-
- throw new InvalidArgumentException('No target ID for this class is existing! (Class: '.$class.')');
- }
}
diff --git a/src/Entity/LogSystem/CollectionElementDeleted.php b/src/Entity/LogSystem/CollectionElementDeleted.php
index 5b12119a..16bf33f5 100644
--- a/src/Entity/LogSystem/CollectionElementDeleted.php
+++ b/src/Entity/LogSystem/CollectionElementDeleted.php
@@ -52,7 +52,7 @@ use App\Entity\Attachments\GroupAttachment;
use App\Entity\Attachments\ManufacturerAttachment;
use App\Entity\Attachments\MeasurementUnitAttachment;
use App\Entity\Attachments\PartAttachment;
-use App\Entity\Attachments\StorelocationAttachment;
+use App\Entity\Attachments\StorageLocationAttachment;
use App\Entity\Attachments\SupplierAttachment;
use App\Entity\Attachments\UserAttachment;
use App\Entity\Base\AbstractDBElement;
@@ -69,40 +69,36 @@ use App\Entity\Parameters\GroupParameter;
use App\Entity\Parameters\ManufacturerParameter;
use App\Entity\Parameters\MeasurementUnitParameter;
use App\Entity\Parameters\PartParameter;
-use App\Entity\Parameters\StorelocationParameter;
+use App\Entity\Parameters\StorageLocationParameter;
use App\Entity\Parameters\SupplierParameter;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
-use App\Entity\Parts\Storelocation;
+use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
-use App\Repository\Parts\ManufacturerRepository;
use Doctrine\ORM\Mapping as ORM;
-use InvalidArgumentException;
-/**
- * @ORM\Entity()
- * This log entry is created when an element is deleted, that is used in a collection of an other entity.
- * This is needed to signal time travel, that it has to undelete the deleted entity.
- */
+#[ORM\Entity]
class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventUndoInterface
{
+ use LogWithEventUndoTrait;
+
protected string $typeString = 'collection_element_deleted';
- protected int $level = self::LEVEL_INFO;
public function __construct(AbstractDBElement $changed_element, string $collection_name, AbstractDBElement $deletedElement)
{
parent::__construct();
- $this->level = self::LEVEL_INFO;
+ $this->level = LogLevel::INFO;
+
$this->setTargetElement($changed_element);
$this->extra['n'] = $collection_name;
- $this->extra['c'] = self::targetTypeClassToID(get_class($deletedElement));
+ $this->extra['c'] = LogTargetType::fromElementClass($deletedElement)->value;
$this->extra['i'] = $deletedElement->getID();
if ($deletedElement instanceof NamedElementInterface) {
$this->extra['o'] = $deletedElement->getName();
@@ -132,7 +128,7 @@ class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventU
public function getDeletedElementClass(): string
{
//The class name of our target element
- $tmp = self::targetTypeIdToClass($this->extra['c']);
+ $tmp = LogTargetType::from($this->extra['c'])->toClass();
$reflection_class = new \ReflectionClass($tmp);
//If the class is abstract, we have to map it to an instantiable class
@@ -146,71 +142,42 @@ class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventU
/**
* This functions maps an abstract class name derived from the extra c element to an instantiable class name (based on the target element of this log entry).
* For example if the target element is a part and the extra c element is "App\Entity\Attachments\Attachment", this function will return "App\Entity\Attachments\PartAttachment".
- * @param string $abstract_class
- * @return string
*/
private function resolveAbstractClassToInstantiableClass(string $abstract_class): string
{
if (is_a($abstract_class, AbstractParameter::class, true)) {
- switch ($this->getTargetClass()) {
- case AttachmentType::class:
- return AttachmentTypeParameter::class;
- case Category::class:
- return CategoryParameter::class;
- case Currency::class:
- return CurrencyParameter::class;
- case Project::class:
- return ProjectParameter::class;
- case Footprint::class:
- return FootprintParameter::class;
- case Group::class:
- return GroupParameter::class;
- case Manufacturer::class:
- return ManufacturerParameter::class;
- case MeasurementUnit::class:
- return MeasurementUnitParameter::class;
- case Part::class:
- return PartParameter::class;
- case Storelocation::class:
- return StorelocationParameter::class;
- case Supplier::class:
- return SupplierParameter::class;
-
- default:
- throw new \RuntimeException('Unknown target class for parameter: '.$this->getTargetClass());
- }
+ return match ($this->getTargetClass()) {
+ AttachmentType::class => AttachmentTypeParameter::class,
+ Category::class => CategoryParameter::class,
+ Currency::class => CurrencyParameter::class,
+ Project::class => ProjectParameter::class,
+ Footprint::class => FootprintParameter::class,
+ Group::class => GroupParameter::class,
+ Manufacturer::class => ManufacturerParameter::class,
+ MeasurementUnit::class => MeasurementUnitParameter::class,
+ Part::class => PartParameter::class,
+ StorageLocation::class => StorageLocationParameter::class,
+ Supplier::class => SupplierParameter::class,
+ default => throw new \RuntimeException('Unknown target class for parameter: '.$this->getTargetClass()),
+ };
}
if (is_a($abstract_class, Attachment::class, true)) {
- switch ($this->getTargetClass()) {
- case AttachmentType::class:
- return AttachmentTypeAttachment::class;
- case Category::class:
- return CategoryAttachment::class;
- case Currency::class:
- return CurrencyAttachment::class;
- case Project::class:
- return ProjectAttachment::class;
- case Footprint::class:
- return FootprintAttachment::class;
- case Group::class:
- return GroupAttachment::class;
- case Manufacturer::class:
- return ManufacturerAttachment::class;
- case MeasurementUnit::class:
- return MeasurementUnitAttachment::class;
- case Part::class:
- return PartAttachment::class;
- case Storelocation::class:
- return StorelocationAttachment::class;
- case Supplier::class:
- return SupplierAttachment::class;
- case User::class:
- return UserAttachment::class;
-
- default:
- throw new \RuntimeException('Unknown target class for parameter: '.$this->getTargetClass());
- }
+ return match ($this->getTargetClass()) {
+ AttachmentType::class => AttachmentTypeAttachment::class,
+ Category::class => CategoryAttachment::class,
+ Currency::class => CurrencyAttachment::class,
+ Project::class => ProjectAttachment::class,
+ Footprint::class => FootprintAttachment::class,
+ Group::class => GroupAttachment::class,
+ Manufacturer::class => ManufacturerAttachment::class,
+ MeasurementUnit::class => MeasurementUnitAttachment::class,
+ Part::class => PartAttachment::class,
+ StorageLocation::class => StorageLocationAttachment::class,
+ Supplier::class => SupplierAttachment::class,
+ User::class => UserAttachment::class,
+ default => throw new \RuntimeException('Unknown target class for parameter: '.$this->getTargetClass()),
+ };
}
throw new \RuntimeException('The class '.$abstract_class.' is abstract and no explicit resolving to an concrete type is defined!');
@@ -223,39 +190,4 @@ class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventU
{
return $this->extra['i'];
}
-
- public function isUndoEvent(): bool
- {
- return isset($this->extra['u']);
- }
-
- public function getUndoEventID(): ?int
- {
- return $this->extra['u'] ?? null;
- }
-
- public function setUndoneEvent(AbstractLogEntry $event, string $mode = 'undo'): LogWithEventUndoInterface
- {
- $this->extra['u'] = $event->getID();
-
- if ('undo' === $mode) {
- $this->extra['um'] = 1;
- } elseif ('revert' === $mode) {
- $this->extra['um'] = 2;
- } else {
- throw new InvalidArgumentException('Passed invalid $mode!');
- }
-
- return $this;
- }
-
- public function getUndoMode(): string
- {
- $mode_int = $this->extra['um'] ?? 1;
- if (1 === $mode_int) {
- return 'undo';
- }
-
- return 'revert';
- }
}
diff --git a/src/Entity/LogSystem/ConfigChangedLogEntry.php b/src/Entity/LogSystem/ConfigChangedLogEntry.php
index 543886bf..68f8edf3 100644
--- a/src/Entity/LogSystem/ConfigChangedLogEntry.php
+++ b/src/Entity/LogSystem/ConfigChangedLogEntry.php
@@ -25,9 +25,7 @@ namespace App\Entity\LogSystem;
use App\Exceptions\LogEntryObsoleteException;
use Doctrine\ORM\Mapping as ORM;
-/**
- * @ORM\Entity()
- */
+#[ORM\Entity]
class ConfigChangedLogEntry extends AbstractLogEntry
{
protected string $typeString = 'config_changed';
diff --git a/src/Entity/LogSystem/DatabaseUpdatedLogEntry.php b/src/Entity/LogSystem/DatabaseUpdatedLogEntry.php
index 9c600365..6e137373 100644
--- a/src/Entity/LogSystem/DatabaseUpdatedLogEntry.php
+++ b/src/Entity/LogSystem/DatabaseUpdatedLogEntry.php
@@ -24,9 +24,7 @@ namespace App\Entity\LogSystem;
use Doctrine\ORM\Mapping as ORM;
-/**
- * @ORM\Entity()
- */
+#[ORM\Entity]
class DatabaseUpdatedLogEntry extends AbstractLogEntry
{
protected string $typeString = 'database_updated';
@@ -43,7 +41,7 @@ class DatabaseUpdatedLogEntry extends AbstractLogEntry
*/
public function isSuccessful(): bool
{
- //We dont save unsuccessful updates now, so just assume it to save space.
+ //We don't save unsuccessful updates now, so just assume it to save space.
return $this->extra['s'] ?? true;
}
diff --git a/src/Entity/LogSystem/ElementCreatedLogEntry.php b/src/Entity/LogSystem/ElementCreatedLogEntry.php
index 2a327e73..8364974c 100644
--- a/src/Entity/LogSystem/ElementCreatedLogEntry.php
+++ b/src/Entity/LogSystem/ElementCreatedLogEntry.php
@@ -28,24 +28,23 @@ use App\Entity\Contracts\LogWithEventUndoInterface;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use Doctrine\ORM\Mapping as ORM;
-use InvalidArgumentException;
-/**
- * @ORM\Entity()
- */
+#[ORM\Entity]
class ElementCreatedLogEntry extends AbstractLogEntry implements LogWithCommentInterface, LogWithEventUndoInterface
{
+ use LogWithEventUndoTrait;
+
protected string $typeString = 'element_created';
public function __construct(AbstractDBElement $new_element)
{
parent::__construct();
- $this->level = self::LEVEL_INFO;
+ $this->level = LogLevel::INFO;
$this->setTargetElement($new_element);
//Creation of new users is maybe more interesting...
if ($new_element instanceof User || $new_element instanceof Group) {
- $this->level = self::LEVEL_NOTICE;
+ $this->level = LogLevel::NOTICE;
}
}
@@ -54,7 +53,7 @@ class ElementCreatedLogEntry extends AbstractLogEntry implements LogWithCommentI
*/
public function getCreationInstockValue(): ?string
{
- return $this->extra['i'] ?? null;
+ return isset($this->extra['i']) ? (string)$this->extra['i'] : null;
}
/**
@@ -81,39 +80,4 @@ class ElementCreatedLogEntry extends AbstractLogEntry implements LogWithCommentI
return $this;
}
-
- public function isUndoEvent(): bool
- {
- return isset($this->extra['u']);
- }
-
- public function getUndoEventID(): ?int
- {
- return $this->extra['u'] ?? null;
- }
-
- public function setUndoneEvent(AbstractLogEntry $event, string $mode = 'undo'): LogWithEventUndoInterface
- {
- $this->extra['u'] = $event->getID();
-
- if ('undo' === $mode) {
- $this->extra['um'] = 1;
- } elseif ('revert' === $mode) {
- $this->extra['um'] = 2;
- } else {
- throw new InvalidArgumentException('Passed invalid $mode!');
- }
-
- return $this;
- }
-
- public function getUndoMode(): string
- {
- $mode_int = $this->extra['um'] ?? 1;
- if (1 === $mode_int) {
- return 'undo';
- }
-
- return 'revert';
- }
}
diff --git a/src/Entity/LogSystem/ElementDeletedLogEntry.php b/src/Entity/LogSystem/ElementDeletedLogEntry.php
index a25f9bf7..e3dd2ac7 100644
--- a/src/Entity/LogSystem/ElementDeletedLogEntry.php
+++ b/src/Entity/LogSystem/ElementDeletedLogEntry.php
@@ -30,24 +30,23 @@ use App\Entity\Contracts\TimeTravelInterface;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use Doctrine\ORM\Mapping as ORM;
-use InvalidArgumentException;
-/**
- * @ORM\Entity()
- */
+#[ORM\Entity]
class ElementDeletedLogEntry extends AbstractLogEntry implements TimeTravelInterface, LogWithCommentInterface, LogWithEventUndoInterface
{
protected string $typeString = 'element_deleted';
+ use LogWithEventUndoTrait;
+
public function __construct(AbstractDBElement $deleted_element)
{
parent::__construct();
- $this->level = self::LEVEL_INFO;
+ $this->level = LogLevel::INFO;
$this->setTargetElement($deleted_element);
//Deletion of a user is maybe more interesting...
if ($deleted_element instanceof User || $deleted_element instanceof Group) {
- $this->level = self::LEVEL_NOTICE;
+ $this->level = LogLevel::NOTICE;
}
}
@@ -88,7 +87,7 @@ class ElementDeletedLogEntry extends AbstractLogEntry implements TimeTravelInter
return $this;
}
- public function hasOldDataInformations(): bool
+ public function hasOldDataInformation(): bool
{
return !empty($this->extra['o']);
}
@@ -114,39 +113,4 @@ class ElementDeletedLogEntry extends AbstractLogEntry implements TimeTravelInter
return $this;
}
-
- public function isUndoEvent(): bool
- {
- return isset($this->extra['u']);
- }
-
- public function getUndoEventID(): ?int
- {
- return $this->extra['u'] ?? null;
- }
-
- public function setUndoneEvent(AbstractLogEntry $event, string $mode = 'undo'): LogWithEventUndoInterface
- {
- $this->extra['u'] = $event->getID();
-
- if ('undo' === $mode) {
- $this->extra['um'] = 1;
- } elseif ('revert' === $mode) {
- $this->extra['um'] = 2;
- } else {
- throw new InvalidArgumentException('Passed invalid $mode!');
- }
-
- return $this;
- }
-
- public function getUndoMode(): string
- {
- $mode_int = $this->extra['um'] ?? 1;
- if (1 === $mode_int) {
- return 'undo';
- }
-
- return 'revert';
- }
}
diff --git a/src/Entity/LogSystem/ElementEditedLogEntry.php b/src/Entity/LogSystem/ElementEditedLogEntry.php
index ec5b9f80..8d4b7b9d 100644
--- a/src/Entity/LogSystem/ElementEditedLogEntry.php
+++ b/src/Entity/LogSystem/ElementEditedLogEntry.php
@@ -25,21 +25,21 @@ namespace App\Entity\LogSystem;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Contracts\LogWithCommentInterface;
use App\Entity\Contracts\LogWithEventUndoInterface;
+use App\Entity\Contracts\LogWithNewDataInterface;
use App\Entity\Contracts\TimeTravelInterface;
use Doctrine\ORM\Mapping as ORM;
-use InvalidArgumentException;
-/**
- * @ORM\Entity()
- */
-class ElementEditedLogEntry extends AbstractLogEntry implements TimeTravelInterface, LogWithCommentInterface, LogWithEventUndoInterface
+#[ORM\Entity]
+class ElementEditedLogEntry extends AbstractLogEntry implements TimeTravelInterface, LogWithCommentInterface, LogWithEventUndoInterface, LogWithNewDataInterface
{
+ use LogWithEventUndoTrait;
+
protected string $typeString = 'element_edited';
public function __construct(AbstractDBElement $changed_element)
{
parent::__construct();
- $this->level = self::LEVEL_INFO;
+ $this->level = LogLevel::INFO;
$this->setTargetElement($changed_element);
}
@@ -49,7 +49,7 @@ class ElementEditedLogEntry extends AbstractLogEntry implements TimeTravelInterf
*/
public function hasChangedFieldsInfo(): bool
{
- return isset($this->extra['f']) || $this->hasOldDataInformations();
+ return isset($this->extra['f']) || $this->hasOldDataInformation();
}
/**
@@ -59,7 +59,7 @@ class ElementEditedLogEntry extends AbstractLogEntry implements TimeTravelInterf
*/
public function getChangedFields(): array
{
- if ($this->hasOldDataInformations()) {
+ if ($this->hasOldDataInformation()) {
return array_keys($this->getOldData());
}
@@ -92,7 +92,29 @@ class ElementEditedLogEntry extends AbstractLogEntry implements TimeTravelInterf
return $this;
}
- public function hasOldDataInformations(): bool
+ public function hasNewDataInformation(): bool
+ {
+ return !empty($this->extra['n']);
+ }
+
+ public function getNewData(): array
+ {
+ return $this->extra['n'] ?? [];
+ }
+
+ /**
+ * Sets the old data for this entry.
+ *
+ * @return $this
+ */
+ public function setNewData(array $new_data): self
+ {
+ $this->extra['n'] = $new_data;
+
+ return $this;
+ }
+
+ public function hasOldDataInformation(): bool
{
return !empty($this->extra['d']);
}
@@ -118,39 +140,4 @@ class ElementEditedLogEntry extends AbstractLogEntry implements TimeTravelInterf
return $this;
}
-
- public function isUndoEvent(): bool
- {
- return isset($this->extra['u']);
- }
-
- public function getUndoEventID(): ?int
- {
- return $this->extra['u'] ?? null;
- }
-
- public function setUndoneEvent(AbstractLogEntry $event, string $mode = 'undo'): LogWithEventUndoInterface
- {
- $this->extra['u'] = $event->getID();
-
- if ('undo' === $mode) {
- $this->extra['um'] = 1;
- } elseif ('revert' === $mode) {
- $this->extra['um'] = 2;
- } else {
- throw new InvalidArgumentException('Passed invalid $mode!');
- }
-
- return $this;
- }
-
- public function getUndoMode(): string
- {
- $mode_int = $this->extra['um'] ?? 1;
- if (1 === $mode_int) {
- return 'undo';
- }
-
- return 'revert';
- }
}
diff --git a/src/Entity/LogSystem/ExceptionLogEntry.php b/src/Entity/LogSystem/ExceptionLogEntry.php
index dc9a7f32..e8fb06f9 100644
--- a/src/Entity/LogSystem/ExceptionLogEntry.php
+++ b/src/Entity/LogSystem/ExceptionLogEntry.php
@@ -25,9 +25,7 @@ namespace App\Entity\LogSystem;
use App\Exceptions\LogEntryObsoleteException;
use Doctrine\ORM\Mapping as ORM;
-/**
- * @ORM\Entity()
- */
+#[ORM\Entity]
class ExceptionLogEntry extends AbstractLogEntry
{
protected string $typeString = 'exception';
diff --git a/src/Entity/LogSystem/LegacyInstockChangedLogEntry.php b/src/Entity/LogSystem/LegacyInstockChangedLogEntry.php
index 35d58592..27f7afe4 100644
--- a/src/Entity/LogSystem/LegacyInstockChangedLogEntry.php
+++ b/src/Entity/LogSystem/LegacyInstockChangedLogEntry.php
@@ -24,9 +24,7 @@ namespace App\Entity\LogSystem;
use Doctrine\ORM\Mapping as ORM;
-/**
- * @ORM\Entity()
- */
+#[ORM\Entity]
class LegacyInstockChangedLogEntry extends AbstractLogEntry
{
protected string $typeString = 'instock_changed';
@@ -56,7 +54,7 @@ class LegacyInstockChangedLogEntry extends AbstractLogEntry
}
/**
- * Returns the price that has to be payed for the change (in the base currency).
+ * Returns the price that has to be paid for the change (in the base currency).
*
* @param bool $absolute Set this to true, if you want only get the absolute value of the price (without minus)
*/
@@ -92,9 +90,9 @@ class LegacyInstockChangedLogEntry extends AbstractLogEntry
}
/**
- * Checks if the Change was an withdrawal of parts.
+ * Checks if the Change was a withdrawal of parts.
*
- * @return bool true if the change was an withdrawal, false if not
+ * @return bool true if the change was a withdrawal, false if not
*/
public function isWithdrawal(): bool
{
diff --git a/src/Entity/LogSystem/LogLevel.php b/src/Entity/LogSystem/LogLevel.php
new file mode 100644
index 00000000..435c5468
--- /dev/null
+++ b/src/Entity/LogSystem/LogLevel.php
@@ -0,0 +1,119 @@
+.
+ */
+namespace App\Entity\LogSystem;
+
+use Psr\Log\LogLevel as PSRLogLevel;
+
+enum LogLevel: int
+{
+ case EMERGENCY = 0;
+ case ALERT = 1;
+ case CRITICAL = 2;
+ case ERROR = 3;
+ case WARNING = 4;
+ case NOTICE = 5;
+ case INFO = 6;
+ case DEBUG = 7;
+
+ /**
+ * Converts the current log level to a PSR-3 log level string.
+ * @return string
+ */
+ public function toPSR3LevelString(): string
+ {
+ return match ($this) {
+ self::EMERGENCY => PSRLogLevel::EMERGENCY,
+ self::ALERT => PSRLogLevel::ALERT,
+ self::CRITICAL => PSRLogLevel::CRITICAL,
+ self::ERROR => PSRLogLevel::ERROR,
+ self::WARNING => PSRLogLevel::WARNING,
+ self::NOTICE => PSRLogLevel::NOTICE,
+ self::INFO => PSRLogLevel::INFO,
+ self::DEBUG => PSRLogLevel::DEBUG,
+ };
+ }
+
+ /**
+ * Creates a log level (enum) from a PSR-3 log level string.
+ * @param string $level
+ * @return self
+ */
+ public static function fromPSR3LevelString(string $level): self
+ {
+ return match ($level) {
+ PSRLogLevel::EMERGENCY => self::EMERGENCY,
+ PSRLogLevel::ALERT => self::ALERT,
+ PSRLogLevel::CRITICAL => self::CRITICAL,
+ PSRLogLevel::ERROR => self::ERROR,
+ PSRLogLevel::WARNING => self::WARNING,
+ PSRLogLevel::NOTICE => self::NOTICE,
+ PSRLogLevel::INFO => self::INFO,
+ PSRLogLevel::DEBUG => self::DEBUG,
+ default => throw new \InvalidArgumentException("Invalid log level: $level"),
+ };
+ }
+
+ /**
+ * Checks if the current log level is more important than the given one.
+ * @param LogLevel $other
+ * @return bool
+ */
+ public function moreImportThan(self $other): bool
+ {
+ //Smaller values are more important
+ return $this->value < $other->value;
+ }
+
+ /**
+ * Checks if the current log level is more important or equal than the given one.
+ * @param LogLevel $other
+ * @return bool
+ */
+ public function moreImportOrEqualThan(self $other): bool
+ {
+ //Smaller values are more important
+ return $this->value <= $other->value;
+ }
+
+ /**
+ * Checks if the current log level is less important than the given one.
+ * @param LogLevel $other
+ * @return bool
+ */
+ public function lessImportThan(self $other): bool
+ {
+ //Bigger values are less important
+ return $this->value > $other->value;
+ }
+
+ /**
+ * Checks if the current log level is less important or equal than the given one.
+ * @param LogLevel $other
+ * @return bool
+ */
+ public function lessImportOrEqualThan(self $other): bool
+ {
+ //Bigger values are less important
+ return $this->value >= $other->value;
+ }
+}
diff --git a/src/Entity/LogSystem/LogTargetType.php b/src/Entity/LogSystem/LogTargetType.php
new file mode 100644
index 00000000..1c6e4f8c
--- /dev/null
+++ b/src/Entity/LogSystem/LogTargetType.php
@@ -0,0 +1,129 @@
+.
+ */
+namespace App\Entity\LogSystem;
+
+use App\Entity\Attachments\Attachment;
+use App\Entity\Attachments\AttachmentType;
+use App\Entity\LabelSystem\LabelProfile;
+use App\Entity\Parameters\AbstractParameter;
+use App\Entity\Parts\Category;
+use App\Entity\Parts\Footprint;
+use App\Entity\Parts\Manufacturer;
+use App\Entity\Parts\MeasurementUnit;
+use App\Entity\Parts\Part;
+use App\Entity\Parts\PartAssociation;
+use App\Entity\Parts\PartLot;
+use App\Entity\Parts\StorageLocation;
+use App\Entity\Parts\Supplier;
+use App\Entity\PriceInformations\Currency;
+use App\Entity\PriceInformations\Orderdetail;
+use App\Entity\PriceInformations\Pricedetail;
+use App\Entity\ProjectSystem\Project;
+use App\Entity\ProjectSystem\ProjectBOMEntry;
+use App\Entity\UserSystem\Group;
+use App\Entity\UserSystem\User;
+
+enum LogTargetType: int
+{
+ case NONE = 0;
+ case USER = 1;
+ case ATTACHMENT = 2;
+ case ATTACHMENT_TYPE = 3;
+ case CATEGORY = 4;
+ case PROJECT = 5;
+ case BOM_ENTRY = 6;
+ case FOOTPRINT = 7;
+ case GROUP = 8;
+ case MANUFACTURER = 9;
+ case PART = 10;
+ case STORELOCATION = 11;
+ case SUPPLIER = 12;
+ case PART_LOT = 13;
+ case CURRENCY = 14;
+ case ORDERDETAIL = 15;
+ case PRICEDETAIL = 16;
+ case MEASUREMENT_UNIT = 17;
+ case PARAMETER = 18;
+ case LABEL_PROFILE = 19;
+
+ case PART_ASSOCIATION = 20;
+
+ /**
+ * Returns the class name of the target type or null if the target type is NONE.
+ * @return string|null
+ */
+ public function toClass(): ?string
+ {
+ return match ($this) {
+ self::NONE => null,
+ self::USER => User::class,
+ self::ATTACHMENT => Attachment::class,
+ self::ATTACHMENT_TYPE => AttachmentType::class,
+ self::CATEGORY => Category::class,
+ self::PROJECT => Project::class,
+ self::BOM_ENTRY => ProjectBOMEntry::class,
+ self::FOOTPRINT => Footprint::class,
+ self::GROUP => Group::class,
+ self::MANUFACTURER => Manufacturer::class,
+ self::PART => Part::class,
+ self::STORELOCATION => StorageLocation::class,
+ self::SUPPLIER => Supplier::class,
+ self::PART_LOT => PartLot::class,
+ self::CURRENCY => Currency::class,
+ self::ORDERDETAIL => Orderdetail::class,
+ self::PRICEDETAIL => Pricedetail::class,
+ self::MEASUREMENT_UNIT => MeasurementUnit::class,
+ self::PARAMETER => AbstractParameter::class,
+ self::LABEL_PROFILE => LabelProfile::class,
+ self::PART_ASSOCIATION => PartAssociation::class,
+ };
+ }
+
+ /**
+ * Determines the target type from the given class name or object.
+ * @param object|string $element
+ * @phpstan-param object|class-string $element
+ * @return self
+ */
+ public static function fromElementClass(object|string $element): self
+ {
+ //Iterate over all possible types
+ foreach (self::cases() as $case) {
+ $class = $case->toClass();
+
+ //Skip NONE
+ if ($class === null) {
+ continue;
+ }
+
+ //Check if the given element is a instance of the class
+ if (is_a($element, $class, true)) {
+ return $case;
+ }
+ }
+
+ $elementClass = is_object($element) ? $element::class : $element;
+ //If no matching type was found, throw an exception
+ throw new \InvalidArgumentException("The given class $elementClass is not a valid log target type.");
+ }
+}
diff --git a/src/Entity/LogSystem/LogWithEventUndoTrait.php b/src/Entity/LogSystem/LogWithEventUndoTrait.php
new file mode 100644
index 00000000..ed8629dc
--- /dev/null
+++ b/src/Entity/LogSystem/LogWithEventUndoTrait.php
@@ -0,0 +1,53 @@
+.
+ */
+namespace App\Entity\LogSystem;
+
+use App\Entity\Contracts\LogWithEventUndoInterface;
+use App\Services\LogSystem\EventUndoMode;
+
+trait LogWithEventUndoTrait
+{
+ public function isUndoEvent(): bool
+ {
+ return isset($this->extra['u']);
+ }
+
+ public function getUndoEventID(): ?int
+ {
+ return $this->extra['u'] ?? null;
+ }
+
+ public function setUndoneEvent(AbstractLogEntry $event, EventUndoMode $mode = EventUndoMode::UNDO): LogWithEventUndoInterface
+ {
+ $this->extra['u'] = $event->getID();
+ $this->extra['um'] = $mode->toExtraInt();
+
+ return $this;
+ }
+
+ public function getUndoMode(): EventUndoMode
+ {
+ $mode_int = $this->extra['um'] ?? 1;
+ return EventUndoMode::fromExtraInt($mode_int);
+ }
+}
diff --git a/src/Entity/LogSystem/PartStockChangeType.php b/src/Entity/LogSystem/PartStockChangeType.php
new file mode 100644
index 00000000..f69fe95f
--- /dev/null
+++ b/src/Entity/LogSystem/PartStockChangeType.php
@@ -0,0 +1,58 @@
+.
+ */
+namespace App\Entity\LogSystem;
+
+enum PartStockChangeType: string
+{
+ case ADD = "add";
+ case WITHDRAW = "withdraw";
+ case MOVE = "move";
+
+ /**
+ * Converts the type to a short representation usable in the extra field of the log entry.
+ * @return string
+ */
+ public function toExtraShortType(): string
+ {
+ return match ($this) {
+ self::ADD => 'a',
+ self::WITHDRAW => 'w',
+ self::MOVE => 'm',
+ };
+ }
+
+ public function toTranslationKey(): string
+ {
+ return 'log.part_stock_changed.' . $this->value;
+ }
+
+ public static function fromExtraShortType(string $value): self
+ {
+ return match ($value) {
+ 'a' => self::ADD,
+ 'w' => self::WITHDRAW,
+ 'm' => self::MOVE,
+ default => throw new \InvalidArgumentException("Invalid short type: $value"),
+ };
+ }
+}
diff --git a/src/Entity/LogSystem/PartStockChangedLogEntry.php b/src/Entity/LogSystem/PartStockChangedLogEntry.php
index 44852076..1bac9e9f 100644
--- a/src/Entity/LogSystem/PartStockChangedLogEntry.php
+++ b/src/Entity/LogSystem/PartStockChangedLogEntry.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Entity\LogSystem;
use App\Entity\Parts\PartLot;
use Doctrine\ORM\Mapping as ORM;
-/**
- * @ORM\Entity()
- */
+#[ORM\Entity]
class PartStockChangedLogEntry extends AbstractLogEntry
{
- public const TYPE_ADD = "add";
- public const TYPE_WITHDRAW = "withdraw";
- public const TYPE_MOVE = "move";
-
protected string $typeString = 'part_stock_changed';
protected const COMMENT_MAX_LENGTH = 300;
/**
* Creates a new part stock changed log entry.
- * @param string $type The type of the log entry. One of the TYPE_* constants.
+ * @param PartStockChangeType $type The type of the log entry.
* @param PartLot $lot The part lot which has been changed.
* @param float $old_stock The old stock of the lot.
* @param float $new_stock The new stock of the lot.
* @param float $new_total_part_instock The new total instock of the part.
* @param string $comment The comment associated with the change.
* @param PartLot|null $move_to_target The target lot if the type is TYPE_MOVE.
+ * @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
*/
- protected function __construct(string $type, PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?PartLot $move_to_target = null)
+ protected function __construct(PartStockChangeType $type, PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?PartLot $move_to_target = null,
+ ?\DateTimeInterface $action_timestamp = null)
{
parent::__construct();
- if (!in_array($type, [self::TYPE_ADD, self::TYPE_WITHDRAW, self::TYPE_MOVE], true)) {
- throw new \InvalidArgumentException('Invalid type for PartStockChangedLogEntry!');
- }
-
//Same as every other element change log entry
- $this->level = self::LEVEL_INFO;
+ $this->level = LogLevel::INFO;
$this->setTargetElement($lot);
-
- $this->typeString = 'part_stock_changed';
$this->extra = array_merge($this->extra, [
- 't' => $this->typeToShortType($type),
+ 't' => $type->toExtraShortType(),
'o' => $old_stock,
'n' => $new_stock,
'p' => $new_total_part_instock,
]);
- if (!empty($comment)) {
+ if ($comment !== '') {
$this->extra['c'] = mb_strimwidth($comment, 0, self::COMMENT_MAX_LENGTH, '...');
}
- if ($move_to_target) {
- if ($type !== self::TYPE_MOVE) {
+ if ($action_timestamp instanceof \DateTimeInterface) {
+ //The action timestamp is saved as an ISO 8601 string
+ $this->extra['a'] = $action_timestamp->format(\DateTimeInterface::ATOM);
+ }
+
+ if ($move_to_target instanceof PartLot) {
+ if ($type !== PartStockChangeType::MOVE) {
throw new \InvalidArgumentException('The move_to_target parameter can only be set if the type is "move"!');
}
@@ -86,11 +83,12 @@ class PartStockChangedLogEntry extends AbstractLogEntry
* @param float $new_stock The new stock of the lot.
* @param float $new_total_part_instock The new total instock of the part.
* @param string $comment The comment associated with the change.
- * @return static
+ * @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
+ * @return self
*/
- public static function add(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment): self
+ public static function add(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?\DateTimeInterface $action_timestamp = null): self
{
- return new self(self::TYPE_ADD, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment);
+ return new self(PartStockChangeType::ADD, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, action_timestamp: $action_timestamp);
}
/**
@@ -100,11 +98,12 @@ class PartStockChangedLogEntry extends AbstractLogEntry
* @param float $new_stock The new stock of the lot.
* @param float $new_total_part_instock The new total instock of the part.
* @param string $comment The comment associated with the change.
- * @return static
+ * @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
+ * @return self
*/
- public static function withdraw(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment): self
+ public static function withdraw(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?\DateTimeInterface $action_timestamp = null): self
{
- return new self(self::TYPE_WITHDRAW, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment);
+ return new self(PartStockChangeType::WITHDRAW, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, action_timestamp: $action_timestamp);
}
/**
@@ -115,24 +114,25 @@ class PartStockChangedLogEntry extends AbstractLogEntry
* @param float $new_total_part_instock The new total instock of the part.
* @param string $comment The comment associated with the change.
* @param PartLot $move_to_target The target lot.
+ * @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
+ * @return self
*/
- public static function move(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, PartLot $move_to_target): self
+ public static function move(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, PartLot $move_to_target, ?\DateTimeInterface $action_timestamp = null): self
{
- return new self(self::TYPE_MOVE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, $move_to_target);
+ return new self(PartStockChangeType::MOVE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, $move_to_target, action_timestamp: $action_timestamp);
}
/**
* Returns the instock change type of this entry
- * @return string One of the TYPE_* constants.
+ * @return PartStockChangeType
*/
- public function getInstockChangeType(): string
+ public function getInstockChangeType(): PartStockChangeType
{
- return $this->shortTypeToType($this->extra['t']);
+ return PartStockChangeType::fromExtraShortType($this->extra['t']);
}
/**
* Returns the old stock of the lot.
- * @return float
*/
public function getOldStock(): float
{
@@ -141,7 +141,6 @@ class PartStockChangedLogEntry extends AbstractLogEntry
/**
* Returns the new stock of the lot.
- * @return float
*/
public function getNewStock(): float
{
@@ -150,7 +149,6 @@ class PartStockChangedLogEntry extends AbstractLogEntry
/**
* Returns the new total instock of the part.
- * @return float
*/
public function getNewTotalPartInstock(): float
{
@@ -159,7 +157,6 @@ class PartStockChangedLogEntry extends AbstractLogEntry
/**
* Returns the comment associated with the change.
- * @return string
*/
public function getComment(): string
{
@@ -168,7 +165,6 @@ class PartStockChangedLogEntry extends AbstractLogEntry
/**
* Gets the difference between the old and the new stock value of the lot as a positive number.
- * @return float
*/
public function getChangeAmount(): float
{
@@ -177,7 +173,6 @@ class PartStockChangedLogEntry extends AbstractLogEntry
/**
* Returns the target lot ID (where the instock was moved to) if the type is TYPE_MOVE.
- * @return int|null
*/
public function getMoveToTargetID(): ?int
{
@@ -185,40 +180,16 @@ class PartStockChangedLogEntry extends AbstractLogEntry
}
/**
- * Converts the human-readable type (TYPE_* consts) to the version stored in DB
- * @param string $type
- * @return string
+ * Returns the timestamp when this action was performed and not when the log entry was created.
+ * This is useful if the action happened in the past, and the log entry is created afterwards.
+ * If the timestamp is not set, null is returned.
+ * @return \DateTimeInterface|null
*/
- protected function typeToShortType(string $type): string
+ public function getActionTimestamp(): ?\DateTimeInterface
{
- switch ($type) {
- case self::TYPE_ADD:
- return 'a';
- case self::TYPE_WITHDRAW:
- return 'w';
- case self::TYPE_MOVE:
- return 'm';
- default:
- throw new \InvalidArgumentException('Invalid type: '.$type);
+ if (!empty($this->extra['a'])) {
+ return \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $this->extra['a']);
}
+ return null;
}
-
- /**
- * Converts the short type stored in DB to the human-readable type (TYPE_* consts).
- * @param string $short_type
- * @return string
- */
- protected function shortTypeToType(string $short_type): string
- {
- switch ($short_type) {
- case 'a':
- return self::TYPE_ADD;
- case 'w':
- return self::TYPE_WITHDRAW;
- case 'm':
- return self::TYPE_MOVE;
- default:
- throw new \InvalidArgumentException('Invalid short type: '.$short_type);
- }
- }
-}
\ No newline at end of file
+}
diff --git a/src/Entity/LogSystem/SecurityEventLogEntry.php b/src/Entity/LogSystem/SecurityEventLogEntry.php
index 095996c2..12e8e65e 100644
--- a/src/Entity/LogSystem/SecurityEventLogEntry.php
+++ b/src/Entity/LogSystem/SecurityEventLogEntry.php
@@ -44,18 +44,17 @@ namespace App\Entity\LogSystem;
use App\Entity\Base\AbstractDBElement;
use App\Entity\UserSystem\User;
use App\Events\SecurityEvents;
+use App\Helpers\IPAnonymizer;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
-use Symfony\Component\HttpFoundation\IpUtils;
/**
* This log entry is created when something security related to a user happens.
- *
- * @ORM\Entity()
*/
+#[ORM\Entity]
class SecurityEventLogEntry extends AbstractLogEntry
{
- public const SECURITY_TYPE_MAPPING = [
+ final public const SECURITY_TYPE_MAPPING = [
0 => SecurityEvents::PASSWORD_CHANGED,
1 => SecurityEvents::PASSWORD_RESET,
2 => SecurityEvents::BACKUP_KEYS_RESET,
@@ -65,15 +64,15 @@ class SecurityEventLogEntry extends AbstractLogEntry
6 => SecurityEvents::GOOGLE_DISABLED,
7 => SecurityEvents::TRUSTED_DEVICE_RESET,
8 => SecurityEvents::TFA_ADMIN_RESET,
+ 9 => SecurityEvents::USER_IMPERSONATED,
];
public function __construct(string $type, string $ip_address, bool $anonymize = true)
{
parent::__construct();
- $this->level = self::LEVEL_INFO;
$this->setIPAddress($ip_address, $anonymize);
$this->setEventType($type);
- $this->level = self::LEVEL_NOTICE;
+ $this->level = LogLevel::NOTICE;
}
public function setTargetElement(?AbstractDBElement $element): AbstractLogEntry
@@ -113,11 +112,11 @@ class SecurityEventLogEntry extends AbstractLogEntry
{
$key = $this->extra['e'];
- return static::SECURITY_TYPE_MAPPING[$key] ?? 'unkown';
+ return static::SECURITY_TYPE_MAPPING[$key] ?? 'unknown';
}
/**
- * Return the (anonymized) IP address used to login the user.
+ * Return the (anonymized) IP address used to log in the user.
*/
public function getIPAddress(): string
{
@@ -125,17 +124,17 @@ class SecurityEventLogEntry extends AbstractLogEntry
}
/**
- * Sets the IP address used to login the user.
+ * Sets the IP address used to log in the user.
*
- * @param string $ip the IP address used to login the user
- * @param bool $anonymize Anonymize the IP address (remove last block) to be GPDR compliant
+ * @param string $ip the IP address used to log in the user
+ * @param bool $anonymize Anonymize the IP address (remove last block) to be GDPR compliant
*
* @return $this
*/
public function setIPAddress(string $ip, bool $anonymize = true): self
{
if ($anonymize) {
- $ip = IpUtils::anonymize($ip);
+ $ip = IPAnonymizer::anonymize($ip);
}
$this->extra['i'] = $ip;
diff --git a/src/Entity/LogSystem/UserLoginLogEntry.php b/src/Entity/LogSystem/UserLoginLogEntry.php
index 5d1733ac..0719a740 100644
--- a/src/Entity/LogSystem/UserLoginLogEntry.php
+++ b/src/Entity/LogSystem/UserLoginLogEntry.php
@@ -22,14 +22,14 @@ declare(strict_types=1);
namespace App\Entity\LogSystem;
+use App\Helpers\IPAnonymizer;
use Doctrine\ORM\Mapping as ORM;
-use Symfony\Component\HttpFoundation\IpUtils;
+
/**
* This log entry is created when a user logs in.
- *
- * @ORM\Entity()
*/
+#[ORM\Entity]
class UserLoginLogEntry extends AbstractLogEntry
{
protected string $typeString = 'user_login';
@@ -37,12 +37,12 @@ class UserLoginLogEntry extends AbstractLogEntry
public function __construct(string $ip_address, bool $anonymize = true)
{
parent::__construct();
- $this->level = self::LEVEL_INFO;
+ $this->level = LogLevel::INFO;
$this->setIPAddress($ip_address, $anonymize);
}
/**
- * Return the (anonymized) IP address used to login the user.
+ * Return the (anonymized) IP address used to log in the user.
*/
public function getIPAddress(): string
{
@@ -50,17 +50,17 @@ class UserLoginLogEntry extends AbstractLogEntry
}
/**
- * Sets the IP address used to login the user.
+ * Sets the IP address used to log in the user.
*
- * @param string $ip the IP address used to login the user
- * @param bool $anonymize Anonymize the IP address (remove last block) to be GPDR compliant
+ * @param string $ip the IP address used to log in the user
+ * @param bool $anonymize Anonymize the IP address (remove last block) to be GDPR compliant
*
* @return $this
*/
public function setIPAddress(string $ip, bool $anonymize = true): self
{
if ($anonymize) {
- $ip = IpUtils::anonymize($ip);
+ $ip = IPAnonymizer::anonymize($ip);
}
$this->extra['i'] = $ip;
diff --git a/src/Entity/LogSystem/UserLogoutLogEntry.php b/src/Entity/LogSystem/UserLogoutLogEntry.php
index d3abb931..f9f9a3dc 100644
--- a/src/Entity/LogSystem/UserLogoutLogEntry.php
+++ b/src/Entity/LogSystem/UserLogoutLogEntry.php
@@ -22,12 +22,10 @@ declare(strict_types=1);
namespace App\Entity\LogSystem;
+use App\Helpers\IPAnonymizer;
use Doctrine\ORM\Mapping as ORM;
-use Symfony\Component\HttpFoundation\IpUtils;
-/**
- * @ORM\Entity()
- */
+#[ORM\Entity]
class UserLogoutLogEntry extends AbstractLogEntry
{
protected string $typeString = 'user_logout';
@@ -35,12 +33,12 @@ class UserLogoutLogEntry extends AbstractLogEntry
public function __construct(string $ip_address, bool $anonymize = true)
{
parent::__construct();
- $this->level = self::LEVEL_INFO;
+ $this->level = LogLevel::INFO;
$this->setIPAddress($ip_address, $anonymize);
}
/**
- * Return the (anonymized) IP address used to login the user.
+ * Return the (anonymized) IP address used to log in the user.
*/
public function getIPAddress(): string
{
@@ -48,17 +46,17 @@ class UserLogoutLogEntry extends AbstractLogEntry
}
/**
- * Sets the IP address used to login the user.
+ * Sets the IP address used to log in the user.
*
- * @param string $ip the IP address used to login the user
- * @param bool $anonymize Anonymize the IP address (remove last block) to be GPDR compliant
+ * @param string $ip the IP address used to log in the user
+ * @param bool $anonymize Anonymize the IP address (remove last block) to be GDPR compliant
*
* @return $this
*/
public function setIPAddress(string $ip, bool $anonymize = true): self
{
if ($anonymize) {
- $ip = IpUtils::anonymize($ip);
+ $ip = IPAnonymizer::anonymize($ip);
}
$this->extra['i'] = $ip;
diff --git a/src/Entity/LogSystem/UserNotAllowedLogEntry.php b/src/Entity/LogSystem/UserNotAllowedLogEntry.php
index 5d4e3acd..c570a012 100644
--- a/src/Entity/LogSystem/UserNotAllowedLogEntry.php
+++ b/src/Entity/LogSystem/UserNotAllowedLogEntry.php
@@ -24,9 +24,7 @@ namespace App\Entity\LogSystem;
use Doctrine\ORM\Mapping as ORM;
-/**
- * @ORM\Entity()
- */
+#[ORM\Entity]
class UserNotAllowedLogEntry extends AbstractLogEntry
{
protected string $typeString = 'user_not_allowed';
@@ -34,7 +32,7 @@ class UserNotAllowedLogEntry extends AbstractLogEntry
public function __construct(string $path)
{
parent::__construct();
- $this->level = static::LEVEL_WARNING;
+ $this->level = LogLevel::WARNING;
$this->extra['a'] = $path;
}
diff --git a/src/Entity/OAuthToken.php b/src/Entity/OAuthToken.php
new file mode 100644
index 00000000..bc692369
--- /dev/null
+++ b/src/Entity/OAuthToken.php
@@ -0,0 +1,151 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Entity;
+
+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: Types::TEXT, nullable: true)]
+ private ?string $token = null;
+
+ /** @var \DateTimeImmutable|null The date when the token expires */
+ #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
+ private ?\DateTimeImmutable $expires_at = null;
+
+ /** @var string|null The refresh token for the OAuth2 auth */
+ #[ORM\Column(type: Types::TEXT, nullable: true)]
+ private ?string $refresh_token = null;
+
+ /**
+ * The default expiration time for a authorization token, if no expiration time is given
+ */
+ private const DEFAULT_EXPIRATION_TIME = 3600;
+
+ public function __construct(string $name, ?string $refresh_token, ?string $token = null, ?\DateTimeImmutable $expires_at = null)
+ {
+ //If token is given, you also have to give the expires_at date
+ if ($token !== null && $expires_at === null) {
+ throw new \InvalidArgumentException('If you give a token, you also have to give the expires_at date');
+ }
+
+ //If no refresh_token is given, the token is a client credentials grant token, which must have a token
+ if ($refresh_token === null && $token === null) {
+ throw new \InvalidArgumentException('If you give no refresh_token, you have to give a token!');
+ }
+
+ $this->name = $name;
+ $this->refresh_token = $refresh_token;
+ $this->expires_at = $expires_at;
+ $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): \DateTimeImmutable
+ {
+ return \DateTimeImmutable::createFromFormat('U', (string)$timestamp);
+ }
+
+ public function getToken(): ?string
+ {
+ return $this->token;
+ }
+
+ public function getExpirationDate(): ?\DateTimeImmutable
+ {
+ 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();
+ }
+
+ /**
+ * Returns true if this token is a client credentials grant token (meaning it has no refresh token), and
+ * needs to be refreshed via the client credentials grant.
+ * @return bool
+ */
+ public function isClientCredentialsGrant(): bool
+ {
+ return $this->refresh_token === null;
+ }
+
+ public function replaceWithNewToken(AccessTokenInterface $accessToken): void
+ {
+ $this->token = $accessToken->getToken();
+ $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(): ?int
+ {
+ return $this->expires_at->getTimestamp();
+ }
+
+ public function hasExpired(): bool
+ {
+ return $this->isExpired();
+ }
+
+ public function getValues(): array
+ {
+ return [];
+ }
+}
\ No newline at end of file
diff --git a/src/Entity/Parameters/AbstractParameter.php b/src/Entity/Parameters/AbstractParameter.php
index 5a3f00e3..edcedc3e 100644
--- a/src/Entity/Parameters/AbstractParameter.php
+++ b/src/Entity/Parameters/AbstractParameter.php
@@ -41,100 +41,143 @@ declare(strict_types=1);
namespace App\Entity\Parameters;
+use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
+use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
+use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
+use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use App\ApiPlatform\Filter\LikeFilter;
+use App\Repository\ParameterRepository;
+use App\Validator\UniqueValidatableInterface;
+use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\AbstractNamedDBElement;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use LogicException;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Serializer\Annotation\SerializedName;
+use Symfony\Component\Serializer\Attribute\DiscriminatorMap;
use Symfony\Component\Validator\Constraints as Assert;
use function sprintf;
-/**
- * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
- * @ORM\Table("parameters", indexes={
- * @ORM\Index(name="parameter_name_idx", columns={"name"}),
- * @ORM\Index(name="parameter_group_idx", columns={"param_group"}),
- * @ORM\Index(name="parameter_type_element_idx", columns={"type", "element_id"})
- * })
- * @ORM\InheritanceType("SINGLE_TABLE")
- * @ORM\DiscriminatorColumn(name="type", type="smallint")
- * @ORM\DiscriminatorMap({
- * 0 = "CategoryParameter",
- * 1 = "CurrencyParameter",
- * 2 = "ProjectParameter",
- * 3 = "FootprintParameter",
- * 4 = "GroupParameter",
- * 5 = "ManufacturerParameter",
- * 6 = "MeasurementUnitParameter",
- * 7 = "PartParameter",
- * 8 = "StorelocationParameter",
- * 9 = "SupplierParameter",
- * 10 = "AttachmentTypeParameter"
- * })
- */
-abstract class AbstractParameter extends AbstractNamedDBElement
+#[ORM\Entity(repositoryClass: ParameterRepository::class)]
+#[ORM\InheritanceType('SINGLE_TABLE')]
+#[ORM\DiscriminatorColumn(name: 'type', type: 'smallint')]
+#[ORM\DiscriminatorMap([0 => CategoryParameter::class, 1 => CurrencyParameter::class, 2 => ProjectParameter::class,
+ 3 => FootprintParameter::class, 4 => GroupParameter::class, 5 => ManufacturerParameter::class,
+ 6 => MeasurementUnitParameter::class, 7 => PartParameter::class, 8 => StorageLocationParameter::class,
+ 9 => SupplierParameter::class, 10 => AttachmentTypeParameter::class])]
+#[ORM\Table('parameters')]
+#[ORM\Index(columns: ['name'], name: 'parameter_name_idx')]
+#[ORM\Index(columns: ['param_group'], name: 'parameter_group_idx')]
+#[ORM\Index(columns: ['type', 'element_id'], name: 'parameter_type_element_idx')]
+#[ApiResource(
+ shortName: 'Parameter',
+ operations: [
+ new Get(security: 'is_granted("read", object)'),
+ new Post(securityPostDenormalize: 'is_granted("create", object)'),
+ new Patch(security: 'is_granted("edit", object)'),
+ new Delete(security: 'is_granted("delete", object)'),
+ ],
+ normalizationContext: ['groups' => ['parameter:read', 'parameter:read:standalone', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
+ denormalizationContext: ['groups' => ['parameter:write', 'parameter:write:standalone', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
+)]
+#[ApiFilter(LikeFilter::class, properties: ["name", "symbol", "unit", "group", "value_text"])]
+#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
+#[ApiFilter(RangeFilter::class, properties: ["value_min", "value_typical", "value_max"])]
+#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
+//This discriminator map is required for API platform to know which class to use for deserialization, when creating a new parameter.
+#[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)]
+abstract class AbstractParameter extends AbstractNamedDBElement implements UniqueValidatableInterface
{
+
+ /*
+ * The discriminator map used for API platform. The key should be the same as the api platform short type (the @type JSONLD field).
+ */
+ private const API_DISCRIMINATOR_MAP = ["Part" => PartParameter::class,
+ "AttachmentType" => AttachmentTypeParameter::class, "Category" => CategoryParameter::class, "Currency" => CurrencyParameter::class,
+ "Project" => ProjectParameter::class, "Footprint" => FootprintParameter::class, "Group" => GroupParameter::class,
+ "Manufacturer" => ManufacturerParameter::class, "MeasurementUnit" => MeasurementUnitParameter::class,
+ "StorageLocation" => StorageLocationParameter::class, "Supplier" => SupplierParameter::class];
+
/**
* @var string The class of the element that can be passed to this attachment. Must be overridden in subclasses.
*/
- public const ALLOWED_ELEMENT_CLASS = '';
+ protected const ALLOWED_ELEMENT_CLASS = '';
/**
* @var string The mathematical symbol for this specification. Can be rendered pretty later. Should be short
- * @Assert\Length(max=20)
- * @ORM\Column(type="string", nullable=false)
*/
+ #[Assert\Length(max: 20)]
+ #[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
+ #[ORM\Column(type: Types::STRING)]
protected string $symbol = '';
/**
* @var float|null the guaranteed minimum value of this property
- * @Assert\Type({"float","null"})
- * @Assert\LessThanOrEqual(propertyPath="value_typical", message="parameters.validator.min_lesser_typical")
- * @Assert\LessThan(propertyPath="value_max", message="parameters.validator.min_lesser_max")
- * @ORM\Column(type="float", nullable=true)
*/
+ #[Assert\Type(['float', null])]
+ #[Assert\LessThanOrEqual(propertyPath: 'value_typical', message: 'parameters.validator.min_lesser_typical')]
+ #[Assert\LessThan(propertyPath: 'value_max', message: 'parameters.validator.min_lesser_max')]
+ #[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
+ #[ORM\Column(type: Types::FLOAT, nullable: true)]
protected ?float $value_min = null;
/**
* @var float|null the typical value of this property
- * @Assert\Type({"null", "float"})
- * @ORM\Column(type="float", nullable=true)
*/
+ #[Assert\Type([null, 'float'])]
+ #[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
+ #[ORM\Column(type: Types::FLOAT, nullable: true)]
protected ?float $value_typical = null;
/**
* @var float|null the maximum value of this property
- * @Assert\Type({"float", "null"})
- * @Assert\GreaterThanOrEqual(propertyPath="value_typical", message="parameters.validator.max_greater_typical")
- * @ORM\Column(type="float", nullable=true)
*/
+ #[Assert\Type(['float', null])]
+ #[Assert\GreaterThanOrEqual(propertyPath: 'value_typical', message: 'parameters.validator.max_greater_typical')]
+ #[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
+ #[ORM\Column(type: Types::FLOAT, nullable: true)]
protected ?float $value_max = null;
/**
* @var string The unit in which the value values are given (e.g. V)
- * @ORM\Column(type="string", nullable=false)
*/
+ #[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
+ #[ORM\Column(type: Types::STRING)]
+ #[Assert\Length(max: 50)]
protected string $unit = '';
/**
* @var string a text value for the given property
- * @ORM\Column(type="string", nullable=false)
*/
+ #[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
+ #[ORM\Column(type: Types::STRING)]
+ #[Assert\Length(max: 255)]
protected string $value_text = '';
/**
* @var string the group this parameter belongs to
- * @ORM\Column(type="string", nullable=false, name="param_group")
*/
+ #[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
+ #[ORM\Column(name: 'param_group', type: Types::STRING)]
+ #[Assert\Length(max: 255)]
protected string $group = '';
/**
- * Mapping is done in sub classes.
+ * Mapping is done in subclasses.
*
* @var AbstractDBElement|null the element to which this parameter belongs to
*/
- protected $element;
+ #[Groups(['parameter:read:standalone', 'parameter:write:standalone'])]
+ protected ?AbstractDBElement $element = null;
public function __construct()
{
@@ -163,7 +206,9 @@ abstract class AbstractParameter extends AbstractNamedDBElement
* Return a formatted string version of the values of the string.
* Based on the set values it can return something like this: 34 V (12 V ... 50 V) [Text].
*/
- public function getFormattedValue(): string
+ #[Groups(['parameter:read', 'full'])]
+ #[SerializedName('formatted')]
+ public function getFormattedValue(bool $latex_formatted = false): string
{
//If we just only have text value, return early
if (null === $this->value_typical && null === $this->value_min && null === $this->value_max) {
@@ -173,7 +218,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement
$str = '';
$bracket_opened = false;
if ($this->value_typical) {
- $str .= $this->getValueTypicalWithUnit();
+ $str .= $this->getValueTypicalWithUnit($latex_formatted);
if ($this->value_min || $this->value_max) {
$bracket_opened = true;
$str .= ' (';
@@ -181,11 +226,11 @@ abstract class AbstractParameter extends AbstractNamedDBElement
}
if ($this->value_max && $this->value_min) {
- $str .= $this->getValueMinWithUnit().' ... '.$this->getValueMaxWithUnit();
+ $str .= $this->getValueMinWithUnit($latex_formatted).' ... '.$this->getValueMaxWithUnit($latex_formatted);
} elseif ($this->value_max) {
- $str .= 'max. '.$this->getValueMaxWithUnit();
+ $str .= 'max. '.$this->getValueMaxWithUnit($latex_formatted);
} elseif ($this->value_min) {
- $str .= 'min. '.$this->getValueMinWithUnit();
+ $str .= 'min. '.$this->getValueMinWithUnit($latex_formatted);
}
//Add closing bracket
@@ -193,7 +238,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement
$str .= ')';
}
- if ($this->value_text) {
+ if ($this->value_text !== '' && $this->value_text !== '0') {
$str .= ' ['.$this->value_text.']';
}
@@ -299,31 +344,30 @@ abstract class AbstractParameter extends AbstractNamedDBElement
/**
* Return a formatted version with the minimum value with the unit of this parameter.
*/
- public function getValueTypicalWithUnit(): string
+ public function getValueTypicalWithUnit(bool $with_latex = false): string
{
- return $this->formatWithUnit($this->value_typical);
+ return $this->formatWithUnit($this->value_typical, with_latex: $with_latex);
}
/**
* Return a formatted version with the maximum value with the unit of this parameter.
*/
- public function getValueMaxWithUnit(): string
+ public function getValueMaxWithUnit(bool $with_latex = false): string
{
- return $this->formatWithUnit($this->value_max);
+ return $this->formatWithUnit($this->value_max, with_latex: $with_latex);
}
/**
* Return a formatted version with the typical value with the unit of this parameter.
*/
- public function getValueMinWithUnit(): string
+ public function getValueMinWithUnit(bool $with_latex = false): string
{
- return $this->formatWithUnit($this->value_min);
+ return $this->formatWithUnit($this->value_min, with_latex: $with_latex);
}
/**
* Sets the typical value of this property.
*
- * @param float|null $value_typical
*
* @return $this
*/
@@ -397,13 +441,34 @@ abstract class AbstractParameter extends AbstractNamedDBElement
/**
* Return a string representation and (if possible) with its unit.
*/
- protected function formatWithUnit(float $value, string $format = '%g'): string
+ protected function formatWithUnit(float $value, string $format = '%g', bool $with_latex = false): string
{
$str = sprintf($format, $value);
- if (!empty($this->unit)) {
- return $str.' '.$this->unit;
+ if ($this->unit !== '') {
+
+ if (!$with_latex) {
+ $unit = $this->unit;
+ } else {
+ $unit = '$\mathrm{'.$this->unit.'}$';
+ }
+
+ return $str.' '.$unit;
}
return $str;
}
+
+ /**
+ * Returns the class of the element that is allowed to be associated with this attachment.
+ * @return string
+ */
+ public function getElementClass(): string
+ {
+ return static::ALLOWED_ELEMENT_CLASS;
+ }
+
+ public function getComparableFields(): array
+ {
+ return ['name' => $this->name, 'group' => $this->group, 'element' => $this->element?->getId()];
+ }
}
diff --git a/src/Entity/Parameters/AttachmentTypeParameter.php b/src/Entity/Parameters/AttachmentTypeParameter.php
index aa39a9a6..9a272a7d 100644
--- a/src/Entity/Parameters/AttachmentTypeParameter.php
+++ b/src/Entity/Parameters/AttachmentTypeParameter.php
@@ -42,20 +42,23 @@ declare(strict_types=1);
namespace App\Entity\Parameters;
use App\Entity\Attachments\AttachmentType;
+use App\Entity\Base\AbstractDBElement;
+use App\Repository\ParameterRepository;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
-/**
- * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
- * @UniqueEntity(fields={"name", "group", "element"})
- */
+#[UniqueEntity(fields: ['name', 'group', 'element'])]
+#[ORM\Entity(repositoryClass: ParameterRepository::class)]
class AttachmentTypeParameter extends AbstractParameter
{
- public const ALLOWED_ELEMENT_CLASS = AttachmentType::class;
+ final public const ALLOWED_ELEMENT_CLASS = AttachmentType::class;
/**
* @var AttachmentType the element this para is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\Attachments\AttachmentType", inversedBy="parameters")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
*/
- protected $element;
+ #[ORM\ManyToOne(targetEntity: AttachmentType::class, inversedBy: 'parameters')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
+ protected ?AbstractDBElement $element = null;
}
diff --git a/src/Entity/Parameters/CategoryParameter.php b/src/Entity/Parameters/CategoryParameter.php
index f2ce3807..ecab1740 100644
--- a/src/Entity/Parameters/CategoryParameter.php
+++ b/src/Entity/Parameters/CategoryParameter.php
@@ -41,21 +41,24 @@ declare(strict_types=1);
namespace App\Entity\Parameters;
+use App\Entity\Base\AbstractDBElement;
use App\Entity\Parts\Category;
+use App\Repository\ParameterRepository;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
-/**
- * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
- * @UniqueEntity(fields={"name", "group", "element"})
- */
+#[UniqueEntity(fields: ['name', 'group', 'element'])]
+#[ORM\Entity(repositoryClass: ParameterRepository::class)]
class CategoryParameter extends AbstractParameter
{
- public const ALLOWED_ELEMENT_CLASS = Category::class;
+ final public const ALLOWED_ELEMENT_CLASS = Category::class;
/**
* @var Category the element this para is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\Parts\Category", inversedBy="parameters")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
*/
- protected $element;
+ #[ORM\ManyToOne(targetEntity: Category::class, inversedBy: 'parameters')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
+ protected ?AbstractDBElement $element = null;
}
diff --git a/src/Entity/Parameters/CurrencyParameter.php b/src/Entity/Parameters/CurrencyParameter.php
index c5fa1e2a..9ab09bed 100644
--- a/src/Entity/Parameters/CurrencyParameter.php
+++ b/src/Entity/Parameters/CurrencyParameter.php
@@ -41,24 +41,28 @@ declare(strict_types=1);
namespace App\Entity\Parameters;
+use App\Entity\Base\AbstractDBElement;
use App\Entity\PriceInformations\Currency;
+use App\Repository\ParameterRepository;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
/**
- * A attachment attached to a category element.
- *
- * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
- * @UniqueEntity(fields={"name", "group", "element"})
+ * An attachment attached to a category element.
*/
+#[UniqueEntity(fields: ['name', 'group', 'element'])]
+#[ORM\Entity(repositoryClass: ParameterRepository::class)]
class CurrencyParameter extends AbstractParameter
{
- public const ALLOWED_ELEMENT_CLASS = Currency::class;
+ final public const ALLOWED_ELEMENT_CLASS = Currency::class;
/**
* @var Currency the element this para is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\PriceInformations\Currency", inversedBy="parameters")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
*/
- protected $element;
+ #[ORM\ManyToOne(targetEntity: Currency::class, inversedBy: 'parameters')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
+ protected ?AbstractDBElement $element = null;
}
diff --git a/src/Entity/Parameters/FootprintParameter.php b/src/Entity/Parameters/FootprintParameter.php
index 9c682720..578ddef3 100644
--- a/src/Entity/Parameters/FootprintParameter.php
+++ b/src/Entity/Parameters/FootprintParameter.php
@@ -41,22 +41,25 @@ declare(strict_types=1);
namespace App\Entity\Parameters;
+use App\Entity\Base\AbstractDBElement;
use App\Entity\Parts\Footprint;
+use App\Repository\ParameterRepository;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
-/**
- * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
- * @UniqueEntity(fields={"name", "group", "element"})
- */
+#[UniqueEntity(fields: ['name', 'group', 'element'])]
+#[ORM\Entity(repositoryClass: ParameterRepository::class)]
class FootprintParameter extends AbstractParameter
{
- public const ALLOWED_ELEMENT_CLASS = Footprint::class;
+ final public const ALLOWED_ELEMENT_CLASS = Footprint::class;
/**
* @var Footprint the element this para is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\Parts\Footprint", inversedBy="parameters")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
*/
- protected $element;
+ #[ORM\ManyToOne(targetEntity: Footprint::class, inversedBy: 'parameters')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
+ protected ?AbstractDBElement $element = null;
}
diff --git a/src/Entity/Parameters/GroupParameter.php b/src/Entity/Parameters/GroupParameter.php
index aa15edb8..7fb5540f 100644
--- a/src/Entity/Parameters/GroupParameter.php
+++ b/src/Entity/Parameters/GroupParameter.php
@@ -41,22 +41,25 @@ declare(strict_types=1);
namespace App\Entity\Parameters;
+use App\Entity\Base\AbstractDBElement;
use App\Entity\UserSystem\Group;
+use App\Repository\ParameterRepository;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
-/**
- * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
- * @UniqueEntity(fields={"name", "group", "element"})
- */
+#[UniqueEntity(fields: ['name', 'group', 'element'])]
+#[ORM\Entity(repositoryClass: ParameterRepository::class)]
class GroupParameter extends AbstractParameter
{
- public const ALLOWED_ELEMENT_CLASS = Group::class;
+ final public const ALLOWED_ELEMENT_CLASS = Group::class;
/**
* @var Group the element this para is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\UserSystem\Group", inversedBy="parameters")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
*/
- protected $element;
+ #[ORM\ManyToOne(targetEntity: Group::class, inversedBy: 'parameters')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
+ protected ?AbstractDBElement $element = null;
}
diff --git a/src/Entity/Parameters/ManufacturerParameter.php b/src/Entity/Parameters/ManufacturerParameter.php
index 1f01ce4d..883a78f4 100644
--- a/src/Entity/Parameters/ManufacturerParameter.php
+++ b/src/Entity/Parameters/ManufacturerParameter.php
@@ -41,22 +41,25 @@ declare(strict_types=1);
namespace App\Entity\Parameters;
+use App\Entity\Base\AbstractDBElement;
use App\Entity\Parts\Manufacturer;
+use App\Repository\ParameterRepository;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
-/**
- * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
- * @UniqueEntity(fields={"name", "group", "element"})
- */
+#[UniqueEntity(fields: ['name', 'group', 'element'])]
+#[ORM\Entity(repositoryClass: ParameterRepository::class)]
class ManufacturerParameter extends AbstractParameter
{
- public const ALLOWED_ELEMENT_CLASS = Manufacturer::class;
+ final public const ALLOWED_ELEMENT_CLASS = Manufacturer::class;
/**
* @var Manufacturer the element this para is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\Parts\Manufacturer", inversedBy="parameters")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
*/
- protected $element;
+ #[ORM\ManyToOne(targetEntity: Manufacturer::class, inversedBy: 'parameters')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
+ protected ?AbstractDBElement $element = null;
}
diff --git a/src/Entity/Parameters/MeasurementUnitParameter.php b/src/Entity/Parameters/MeasurementUnitParameter.php
index 7ca4b1c5..09ff81ec 100644
--- a/src/Entity/Parameters/MeasurementUnitParameter.php
+++ b/src/Entity/Parameters/MeasurementUnitParameter.php
@@ -41,22 +41,25 @@ declare(strict_types=1);
namespace App\Entity\Parameters;
+use App\Entity\Base\AbstractDBElement;
use App\Entity\Parts\MeasurementUnit;
+use App\Repository\ParameterRepository;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
-/**
- * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
- * @UniqueEntity(fields={"name", "group", "element"})
- */
+#[UniqueEntity(fields: ['name', 'group', 'element'])]
+#[ORM\Entity(repositoryClass: ParameterRepository::class)]
class MeasurementUnitParameter extends AbstractParameter
{
- public const ALLOWED_ELEMENT_CLASS = MeasurementUnit::class;
+ final public const ALLOWED_ELEMENT_CLASS = MeasurementUnit::class;
/**
* @var MeasurementUnit the element this para is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\Parts\MeasurementUnit", inversedBy="parameters")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
*/
- protected $element;
+ #[ORM\ManyToOne(targetEntity: MeasurementUnit::class, inversedBy: 'parameters')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
+ protected ?AbstractDBElement $element = null;
}
diff --git a/src/Entity/Parameters/ParametersTrait.php b/src/Entity/Parameters/ParametersTrait.php
index c7ccbddf..2ccaa763 100644
--- a/src/Entity/Parameters/ParametersTrait.php
+++ b/src/Entity/Parameters/ParametersTrait.php
@@ -44,20 +44,25 @@ namespace App\Entity\Parameters;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Validator\Constraints as Assert;
+/**
+ * @template-covariant T of AbstractParameter
+ */
trait ParametersTrait
{
/**
* Mapping done in subclasses.
*
* @var Collection
- * @Assert\Valid()
+ * @phpstan-var Collection
*/
- protected $parameters;
+ #[Assert\Valid]
+ protected Collection $parameters;
/**
* Return all associated specifications.
+ * @return Collection
+ * @phpstan-return Collection
*
- * @psalm-return Collection
*/
public function getParameters(): Collection
{
@@ -66,7 +71,7 @@ trait ParametersTrait
/**
* Add a new parameter information.
- *
+ * @phpstan-param T $parameter
* @return $this
*/
public function addParameter(AbstractParameter $parameter): self
@@ -77,6 +82,9 @@ trait ParametersTrait
return $this;
}
+ /**
+ * @phpstan-param T $parameter
+ */
public function removeParameter(AbstractParameter $parameter): self
{
$this->parameters->removeElement($parameter);
@@ -84,6 +92,10 @@ trait ParametersTrait
return $this;
}
+ /**
+ * @return array>
+ * @phpstan-return array>
+ */
public function getGroupedParameters(): array
{
$tmp = [];
diff --git a/src/Entity/Parameters/PartParameter.php b/src/Entity/Parameters/PartParameter.php
index 45c566d9..91b51c00 100644
--- a/src/Entity/Parameters/PartParameter.php
+++ b/src/Entity/Parameters/PartParameter.php
@@ -41,22 +41,28 @@ declare(strict_types=1);
namespace App\Entity\Parameters;
+use App\Entity\Base\AbstractDBElement;
use App\Entity\Parts\Part;
+use App\Repository\ParameterRepository;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
/**
- * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
- * @UniqueEntity(fields={"name", "group", "element"})
+ * @see \App\Tests\Entity\Parameters\PartParameterTest
*/
+#[UniqueEntity(fields: ['name', 'group', 'element'])]
+#[ORM\Entity(repositoryClass: ParameterRepository::class)]
class PartParameter extends AbstractParameter
{
- public const ALLOWED_ELEMENT_CLASS = Part::class;
+ final public const ALLOWED_ELEMENT_CLASS = Part::class;
/**
* @var Part the element this para is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\Parts\Part", inversedBy="parameters")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
*/
- protected $element;
+ #[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'parameters')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
+ protected ?AbstractDBElement $element = null;
}
diff --git a/src/Entity/Parameters/ProjectParameter.php b/src/Entity/Parameters/ProjectParameter.php
index 2961a843..7c3907cd 100644
--- a/src/Entity/Parameters/ProjectParameter.php
+++ b/src/Entity/Parameters/ProjectParameter.php
@@ -41,22 +41,25 @@ declare(strict_types=1);
namespace App\Entity\Parameters;
+use App\Entity\Base\AbstractDBElement;
use App\Entity\ProjectSystem\Project;
+use App\Repository\ParameterRepository;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
-/**
- * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
- * @UniqueEntity(fields={"name", "group", "element"})
- */
+#[UniqueEntity(fields: ['name', 'group', 'element'])]
+#[ORM\Entity(repositoryClass: ParameterRepository::class)]
class ProjectParameter extends AbstractParameter
{
- public const ALLOWED_ELEMENT_CLASS = Project::class;
+ final public const ALLOWED_ELEMENT_CLASS = Project::class;
/**
* @var Project the element this para is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\ProjectSystem\Project", inversedBy="parameters")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
*/
- protected $element;
+ #[ORM\ManyToOne(targetEntity: Project::class, inversedBy: 'parameters')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
+ protected ?AbstractDBElement $element = null;
}
diff --git a/src/Entity/Parameters/StorelocationParameter.php b/src/Entity/Parameters/StorageLocationParameter.php
similarity index 68%
rename from src/Entity/Parameters/StorelocationParameter.php
rename to src/Entity/Parameters/StorageLocationParameter.php
index 2a7aa202..f5cc6415 100644
--- a/src/Entity/Parameters/StorelocationParameter.php
+++ b/src/Entity/Parameters/StorageLocationParameter.php
@@ -41,22 +41,25 @@ declare(strict_types=1);
namespace App\Entity\Parameters;
-use App\Entity\Parts\Storelocation;
+use App\Entity\Base\AbstractDBElement;
+use App\Entity\Parts\StorageLocation;
+use App\Repository\ParameterRepository;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
-/**
- * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
- * @UniqueEntity(fields={"name", "group", "element"})
- */
-class StorelocationParameter extends AbstractParameter
+#[UniqueEntity(fields: ['name', 'group', 'element'])]
+#[ORM\Entity(repositoryClass: ParameterRepository::class)]
+class StorageLocationParameter extends AbstractParameter
{
- public const ALLOWED_ELEMENT_CLASS = Storelocation::class;
+ final public const ALLOWED_ELEMENT_CLASS = StorageLocation::class;
/**
- * @var Storelocation the element this para is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\Parts\Storelocation", inversedBy="parameters")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
+ * @var StorageLocation the element this para is associated with
*/
- protected $element;
+ #[ORM\ManyToOne(targetEntity: StorageLocation::class, inversedBy: 'parameters')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
+ protected ?AbstractDBElement $element = null;
}
diff --git a/src/Entity/Parameters/SupplierParameter.php b/src/Entity/Parameters/SupplierParameter.php
index a40e3e92..6e42206f 100644
--- a/src/Entity/Parameters/SupplierParameter.php
+++ b/src/Entity/Parameters/SupplierParameter.php
@@ -41,22 +41,25 @@ declare(strict_types=1);
namespace App\Entity\Parameters;
+use App\Entity\Base\AbstractDBElement;
use App\Entity\Parts\Supplier;
+use App\Repository\ParameterRepository;
+use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Attribute\Context;
-/**
- * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
- * @UniqueEntity(fields={"name", "group", "element"})
- */
+#[UniqueEntity(fields: ['name', 'group', 'element'])]
+#[ORM\Entity(repositoryClass: ParameterRepository::class)]
class SupplierParameter extends AbstractParameter
{
- public const ALLOWED_ELEMENT_CLASS = Supplier::class;
+ final public const ALLOWED_ELEMENT_CLASS = Supplier::class;
/**
- * @var Supplier the element this para is associated with
- * @ORM\ManyToOne(targetEntity="App\Entity\Parts\Supplier", inversedBy="parameters")
- * @ORM\JoinColumn(name="element_id", referencedColumnName="id", nullable=false, onDelete="CASCADE").
+ * @var Supplier the element this parameter is associated with
*/
- protected $element;
+ #[ORM\ManyToOne(targetEntity: Supplier::class, inversedBy: 'parameters')]
+ #[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
+ #[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
+ protected ?AbstractDBElement $element = null;
}
diff --git a/src/Entity/Parts/AssociationType.php b/src/Entity/Parts/AssociationType.php
new file mode 100644
index 00000000..52a56af2
--- /dev/null
+++ b/src/Entity/Parts/AssociationType.php
@@ -0,0 +1,46 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Entity\Parts;
+
+/**
+ * The values of this enums are used to describe how two parts are associated with each other.
+ */
+enum AssociationType: int
+{
+ /** A user definable association type, which can be described in the comment field */
+ case OTHER = 0;
+ /** The owning part is compatible with the other part */
+ case COMPATIBLE = 1;
+ /** The owning part supersedes the other part (owner is newer version) */
+ case SUPERSEDES = 2;
+
+ /**
+ * Returns the translation key for this association type.
+ * @return string
+ */
+ public function getTranslationKey(): string
+ {
+ return 'part_association.type.' . strtolower($this->name);
+ }
+}
diff --git a/src/Entity/Parts/Category.php b/src/Entity/Parts/Category.php
index eac47877..99ed3c6d 100644
--- a/src/Entity/Parts/Category.php
+++ b/src/Entity/Parts/Category.php
@@ -22,107 +22,190 @@ declare(strict_types=1);
namespace App\Entity\Parts;
+use Doctrine\Common\Collections\Criteria;
+use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
+use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
+use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Link;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\OpenApi\Model\Operation;
+use ApiPlatform\Serializer\Filter\PropertyFilter;
+use App\ApiPlatform\Filter\LikeFilter;
+use App\Entity\Attachments\Attachment;
+use App\Entity\EDA\EDACategoryInfo;
+use App\Repository\Parts\CategoryRepository;
+use Doctrine\DBAL\Types\Types;
+use Doctrine\Common\Collections\ArrayCollection;
use App\Entity\Attachments\CategoryAttachment;
use App\Entity\Base\AbstractPartsContainingDBElement;
+use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parameters\CategoryParameter;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
- * Class AttachmentType.
+ * This entity describes a category, a part can belong to, which is used to group parts by their function.
*
- * @ORM\Entity(repositoryClass="App\Repository\Parts\CategoryRepository")
- * @ORM\Table(name="`categories`", indexes={
- * @ORM\Index(name="category_idx_name", columns={"name"}),
- * @ORM\Index(name="category_idx_parent_name", columns={"parent_id", "name"}),
- * })
+ * @extends AbstractPartsContainingDBElement
*/
+#[ORM\Entity(repositoryClass: CategoryRepository::class)]
+#[ORM\Table(name: '`categories`')]
+#[ORM\Index(columns: ['name'], name: 'category_idx_name')]
+#[ORM\Index(columns: ['parent_id', 'name'], name: 'category_idx_parent_name')]
+#[ApiResource(
+ operations: [
+ new Get(security: 'is_granted("read", object)'),
+ new GetCollection(security: 'is_granted("@categories.read")'),
+ new Post(securityPostDenormalize: 'is_granted("create", object)'),
+ new Patch(security: 'is_granted("edit", object)'),
+ new Delete(security: 'is_granted("delete", object)'),
+ ],
+ normalizationContext: ['groups' => ['category:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
+ denormalizationContext: ['groups' => ['category:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
+)]
+#[ApiResource(
+ uriTemplate: '/categories/{id}/children.{_format}',
+ operations: [
+ new GetCollection(
+ openapi: new Operation(summary: 'Retrieves the children elements of a category.'),
+ security: 'is_granted("@categories.read")'
+ )
+ ],
+ uriVariables: [
+ 'id' => new Link(fromProperty: 'children', fromClass: Category::class)
+ ],
+ normalizationContext: ['groups' => ['category:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
+)]
+#[ApiFilter(PropertyFilter::class)]
+#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
+#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
+#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class Category extends AbstractPartsContainingDBElement
{
- /**
- * @ORM\OneToMany(targetEntity="Category", mappedBy="parent")
- * @ORM\OrderBy({"name" = "ASC"})
- * @var Collection
- */
- protected $children;
+ #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ protected Collection $children;
+
+ #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
+ #[ORM\JoinColumn(name: 'parent_id')]
+ #[Groups(['category:read', 'category:write'])]
+ #[ApiProperty(readableLink: false, writableLink: false)]
+ protected ?AbstractStructuralDBElement $parent = null;
+
+ #[Groups(['category:read', 'category:write'])]
+ protected string $comment = '';
/**
- * @ORM\ManyToOne(targetEntity="Category", inversedBy="children")
- * @ORM\JoinColumn(name="parent_id", referencedColumnName="id")
- */
- protected $parent;
-
- /**
- * @var string
- * @ORM\Column(type="text")
+ * @var string The hint which is shown as hint under the partname field, when a part is created in this category.
*/
+ #[Groups(['full', 'import', 'category:read', 'category:write'])]
+ #[ORM\Column(type: Types::TEXT)]
protected string $partname_hint = '';
/**
- * @var string
- * @ORM\Column(type="text")
+ * @var string The regular expression which is used to validate the partname of a part in this category.
*/
+ #[Groups(['full', 'import', 'category:read', 'category:write'])]
+ #[ORM\Column(type: Types::TEXT)]
protected string $partname_regex = '';
/**
- * @var bool
- * @ORM\Column(type="boolean")
+ * @var bool Set to true, if the footprints should be disabled for parts this category (not implemented yet).
*/
+ #[Groups(['full', 'import', 'category:read', 'category:write'])]
+ #[ORM\Column(type: Types::BOOLEAN)]
protected bool $disable_footprints = false;
/**
- * @var bool
- * @ORM\Column(type="boolean")
+ * @var bool Set to true, if the manufacturers should be disabled for parts this category (not implemented yet).
*/
+ #[Groups(['full', 'import', 'category:read', 'category:write'])]
+ #[ORM\Column(type: Types::BOOLEAN)]
protected bool $disable_manufacturers = false;
/**
- * @var bool
- * @ORM\Column(type="boolean")
+ * @var bool Set to true, if the autodatasheets should be disabled for parts this category (not implemented yet).
*/
+ #[Groups(['full', 'import', 'category:read', 'category:write'])]
+ #[ORM\Column(type: Types::BOOLEAN)]
protected bool $disable_autodatasheets = false;
/**
- * @var bool
- * @ORM\Column(type="boolean")
+ * @var bool Set to true, if the properties should be disabled for parts this category (not implemented yet).
*/
+ #[Groups(['full', 'import', 'category:read', 'category:write'])]
+ #[ORM\Column(type: Types::BOOLEAN)]
protected bool $disable_properties = false;
/**
- * @var string
- * @ORM\Column(type="text")
+ * @var string The default description for parts in this category.
*/
+ #[Groups(['full', 'import', 'category:read', 'category:write'])]
+ #[ORM\Column(type: Types::TEXT)]
protected string $default_description = '';
/**
- * @var string
- * @ORM\Column(type="text")
+ * @var string The default comment for parts in this category.
*/
+ #[Groups(['full', 'import', 'category:read', 'category:write'])]
+ #[ORM\Column(type: Types::TEXT)]
protected string $default_comment = '';
+
/**
* @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Attachments\CategoryAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"name" = "ASC"})
- * @Assert\Valid()
*/
- protected $attachments;
+ #[Assert\Valid]
+ #[Groups(['full', 'category:read', 'category:write'])]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: CategoryAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ protected Collection $attachments;
+
+ #[ORM\ManyToOne(targetEntity: CategoryAttachment::class)]
+ #[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
+ #[Groups(['category:read', 'category:write'])]
+ protected ?Attachment $master_picture_attachment = null;
/** @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Parameters\CategoryParameter", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"group" = "ASC" ,"name" = "ASC"})
- * @Assert\Valid()
*/
- protected $parameters;
+ #[Assert\Valid]
+ #[Groups(['full', 'category:read', 'category:write'])]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: CategoryParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
+ protected Collection $parameters;
+
+ #[Groups(['category:read'])]
+ protected ?\DateTimeImmutable $addedDate = null;
+ #[Groups(['category:read'])]
+ protected ?\DateTimeImmutable $lastModified = null;
+
+ #[Assert\Valid]
+ #[ORM\Embedded(class: EDACategoryInfo::class)]
+ #[Groups(['full', 'category:read', 'category:write'])]
+ protected EDACategoryInfo $eda_info;
+
+ public function __construct()
+ {
+ parent::__construct();
+ $this->children = new ArrayCollection();
+ $this->attachments = new ArrayCollection();
+ $this->parameters = new ArrayCollection();
+ $this->eda_info = new EDACategoryInfo();
+ }
public function getPartnameHint(): string
{
return $this->partname_hint;
}
- /**
- * @return Category
- */
public function setPartnameHint(string $partname_hint): self
{
$this->partname_hint = $partname_hint;
@@ -135,9 +218,6 @@ class Category extends AbstractPartsContainingDBElement
return $this->partname_regex;
}
- /**
- * @return Category
- */
public function setPartnameRegex(string $partname_regex): self
{
$this->partname_regex = $partname_regex;
@@ -150,9 +230,6 @@ class Category extends AbstractPartsContainingDBElement
return $this->disable_footprints;
}
- /**
- * @return Category
- */
public function setDisableFootprints(bool $disable_footprints): self
{
$this->disable_footprints = $disable_footprints;
@@ -165,9 +242,6 @@ class Category extends AbstractPartsContainingDBElement
return $this->disable_manufacturers;
}
- /**
- * @return Category
- */
public function setDisableManufacturers(bool $disable_manufacturers): self
{
$this->disable_manufacturers = $disable_manufacturers;
@@ -180,9 +254,6 @@ class Category extends AbstractPartsContainingDBElement
return $this->disable_autodatasheets;
}
- /**
- * @return Category
- */
public function setDisableAutodatasheets(bool $disable_autodatasheets): self
{
$this->disable_autodatasheets = $disable_autodatasheets;
@@ -195,9 +266,6 @@ class Category extends AbstractPartsContainingDBElement
return $this->disable_properties;
}
- /**
- * @return Category
- */
public function setDisableProperties(bool $disable_properties): self
{
$this->disable_properties = $disable_properties;
@@ -210,9 +278,6 @@ class Category extends AbstractPartsContainingDBElement
return $this->default_description;
}
- /**
- * @return Category
- */
public function setDefaultDescription(string $default_description): self
{
$this->default_description = $default_description;
@@ -225,13 +290,20 @@ class Category extends AbstractPartsContainingDBElement
return $this->default_comment;
}
- /**
- * @return Category
- */
public function setDefaultComment(string $default_comment): self
{
$this->default_comment = $default_comment;
+ return $this;
+ }
+ public function getEdaInfo(): EDACategoryInfo
+ {
+ return $this->eda_info;
+ }
+
+ public function setEdaInfo(EDACategoryInfo $eda_info): Category
+ {
+ $this->eda_info = $eda_info;
return $this;
}
}
diff --git a/src/Entity/Parts/Footprint.php b/src/Entity/Parts/Footprint.php
index cac63fe2..6b043562 100644
--- a/src/Entity/Parts/Footprint.php
+++ b/src/Entity/Parts/Footprint.php
@@ -22,58 +22,135 @@ declare(strict_types=1);
namespace App\Entity\Parts;
+use Doctrine\Common\Collections\Criteria;
+use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
+use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
+use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Link;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\OpenApi\Model\Operation;
+use ApiPlatform\Serializer\Filter\PropertyFilter;
+use App\ApiPlatform\Filter\LikeFilter;
+use App\Entity\Attachments\Attachment;
+use App\Entity\EDA\EDAFootprintInfo;
+use App\Repository\Parts\FootprintRepository;
+use App\Entity\Base\AbstractStructuralDBElement;
+use Doctrine\Common\Collections\ArrayCollection;
use App\Entity\Attachments\FootprintAttachment;
use App\Entity\Base\AbstractPartsContainingDBElement;
use App\Entity\Parameters\FootprintParameter;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
- * Class Footprint.
+ * This entity represents a footprint of a part (its physical dimensions and shape).
*
- * @ORM\Entity(repositoryClass="App\Repository\Parts\FootprintRepository")
- * @ORM\Table("`footprints`", indexes={
- * @ORM\Index(name="footprint_idx_name", columns={"name"}),
- * @ORM\Index(name="footprint_idx_parent_name", columns={"parent_id", "name"}),
- * })
+ * @extends AbstractPartsContainingDBElement
*/
+#[ORM\Entity(repositoryClass: FootprintRepository::class)]
+#[ORM\Table('`footprints`')]
+#[ORM\Index(columns: ['name'], name: 'footprint_idx_name')]
+#[ORM\Index(columns: ['parent_id', 'name'], name: 'footprint_idx_parent_name')]
+#[ApiResource(
+ operations: [
+ new Get(security: 'is_granted("read", object)'),
+ new GetCollection(security: 'is_granted("@footprints.read")'),
+ new Post(securityPostDenormalize: 'is_granted("create", object)'),
+ new Patch(security: 'is_granted("edit", object)'),
+ new Delete(security: 'is_granted("delete", object)'),
+ ],
+ normalizationContext: ['groups' => ['footprint:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
+ denormalizationContext: ['groups' => ['footprint:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
+)]
+#[ApiResource(
+ uriTemplate: '/footprints/{id}/children.{_format}',
+ operations: [
+ new GetCollection(
+ openapi: new Operation(summary: 'Retrieves the children elements of a footprint.'),
+ security: 'is_granted("@footprints.read")'
+ )
+ ],
+ uriVariables: [
+ 'id' => new Link(fromProperty: 'children', fromClass: Footprint::class)
+ ],
+ normalizationContext: ['groups' => ['footprint:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
+)]
+#[ApiFilter(PropertyFilter::class)]
+#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
+#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
+#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class Footprint extends AbstractPartsContainingDBElement
{
- /**
- * @ORM\ManyToOne(targetEntity="Footprint", inversedBy="children")
- * @ORM\JoinColumn(name="parent_id", referencedColumnName="id")
- */
- protected $parent;
+ #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
+ #[ORM\JoinColumn(name: 'parent_id')]
+ #[Groups(['footprint:read', 'footprint:write'])]
+ #[ApiProperty(readableLink: false, writableLink: false)]
+ protected ?AbstractStructuralDBElement $parent = null;
- /**
- * @ORM\OneToMany(targetEntity="Footprint", mappedBy="parent")
- * @ORM\OrderBy({"name" = "ASC"})
- * @var Collection
- */
- protected $children;
+ #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ protected Collection $children;
+
+ #[Groups(['footprint:read', 'footprint:write'])]
+ protected string $comment = '';
/**
* @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Attachments\FootprintAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"name" = "ASC"})
- * @Assert\Valid()
*/
- protected $attachments;
+ #[Assert\Valid]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: FootprintAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ #[Groups(['footprint:read', 'footprint:write'])]
+ protected Collection $attachments;
+
+ #[ORM\ManyToOne(targetEntity: FootprintAttachment::class)]
+ #[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
+ #[Groups(['footprint:read', 'footprint:write'])]
+ protected ?Attachment $master_picture_attachment = null;
/**
* @var FootprintAttachment|null
- * @ORM\ManyToOne(targetEntity="App\Entity\Attachments\FootprintAttachment")
- * @ORM\JoinColumn(name="id_footprint_3d", referencedColumnName="id")
*/
+ #[ORM\ManyToOne(targetEntity: FootprintAttachment::class)]
+ #[ORM\JoinColumn(name: 'id_footprint_3d')]
+ #[Groups(['footprint:read', 'footprint:write'])]
protected ?FootprintAttachment $footprint_3d = null;
/** @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Parameters\FootprintParameter", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"group" = "ASC" ,"name" = "ASC"})
- * @Assert\Valid()
*/
- protected $parameters;
+ #[Assert\Valid]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: FootprintParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
+ #[Groups(['footprint:read', 'footprint:write'])]
+ protected Collection $parameters;
+
+ #[Groups(['footprint:read'])]
+ protected ?\DateTimeImmutable $addedDate = null;
+ #[Groups(['footprint:read'])]
+ protected ?\DateTimeImmutable $lastModified = null;
+
+ #[Assert\Valid]
+ #[ORM\Embedded(class: EDAFootprintInfo::class)]
+ #[Groups(['full', 'footprint:read', 'footprint:write'])]
+ protected EDAFootprintInfo $eda_info;
+
+ public function __construct()
+ {
+ parent::__construct();
+ $this->children = new ArrayCollection();
+ $this->attachments = new ArrayCollection();
+ $this->parameters = new ArrayCollection();
+ $this->eda_info = new EDAFootprintInfo();
+ }
/****************************************
* Getters
@@ -92,13 +169,10 @@ class Footprint extends AbstractPartsContainingDBElement
* Setters
*
*********************************************************************************/
-
/**
* Sets the 3D Model associated with this footprint.
*
* @param FootprintAttachment|null $new_attachment The new 3D Model
- *
- * @return Footprint
*/
public function setFootprint3d(?FootprintAttachment $new_attachment): self
{
@@ -106,4 +180,15 @@ class Footprint extends AbstractPartsContainingDBElement
return $this;
}
+
+ public function getEdaInfo(): EDAFootprintInfo
+ {
+ return $this->eda_info;
+ }
+
+ public function setEdaInfo(EDAFootprintInfo $eda_info): Footprint
+ {
+ $this->eda_info = $eda_info;
+ return $this;
+ }
}
diff --git a/src/Entity/Parts/InfoProviderReference.php b/src/Entity/Parts/InfoProviderReference.php
new file mode 100644
index 00000000..bfa62f32
--- /dev/null
+++ b/src/Entity/Parts/InfoProviderReference.php
@@ -0,0 +1,160 @@
+.
+ */
+
+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;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * This class represents a reference to a info provider inside a part.
+ * @see \App\Tests\Entity\Parts\InfoProviderReferenceTest
+ */
+#[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: Types::STRING, nullable: true)]
+ #[Groups(['provider_reference:read', 'full'])]
+ 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: Types::STRING, nullable: true)]
+ #[Groups(['provider_reference:read', 'full'])]
+ 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: Types::STRING, nullable: true)]
+ #[Groups(['provider_reference:read', 'full'])]
+ private ?string $provider_url = null;
+
+ #[Column(type: Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])]
+ #[Groups(['provider_reference:read', 'full'])]
+ private ?\DateTimeImmutable $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.
+ */
+ public function getLastUpdated(): ?\DateTimeImmutable
+ {
+ 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/Manufacturer.php b/src/Entity/Parts/Manufacturer.php
index 45e4d140..0edf8232 100644
--- a/src/Entity/Parts/Manufacturer.php
+++ b/src/Entity/Parts/Manufacturer.php
@@ -22,49 +22,112 @@ declare(strict_types=1);
namespace App\Entity\Parts;
+use Doctrine\Common\Collections\Criteria;
+use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
+use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
+use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Link;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\OpenApi\Model\Operation;
+use ApiPlatform\Serializer\Filter\PropertyFilter;
+use App\ApiPlatform\Filter\LikeFilter;
+use App\Entity\Attachments\Attachment;
+use App\Repository\Parts\ManufacturerRepository;
+use App\Entity\Base\AbstractStructuralDBElement;
+use Doctrine\Common\Collections\ArrayCollection;
use App\Entity\Attachments\ManufacturerAttachment;
use App\Entity\Base\AbstractCompany;
use App\Entity\Parameters\ManufacturerParameter;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
- * Class Manufacturer.
+ * This entity represents a manufacturer of a part (The company that produces the part).
*
- * @ORM\Entity(repositoryClass="App\Repository\Parts\ManufacturerRepository")
- * @ORM\Table("`manufacturers`", indexes={
- * @ORM\Index(name="manufacturer_name", columns={"name"}),
- * @ORM\Index(name="manufacturer_idx_parent_name", columns={"parent_id", "name"}),
- * })
+ * @extends AbstractCompany
*/
+#[ORM\Entity(repositoryClass: ManufacturerRepository::class)]
+#[ORM\Table('`manufacturers`')]
+#[ORM\Index(columns: ['name'], name: 'manufacturer_name')]
+#[ORM\Index(columns: ['parent_id', 'name'], name: 'manufacturer_idx_parent_name')]
+#[ApiResource(
+ operations: [
+ new Get(security: 'is_granted("read", object)'),
+ new GetCollection(security: 'is_granted("@manufacturers.read")'),
+ new Post(securityPostDenormalize: 'is_granted("create", object)'),
+ new Patch(security: 'is_granted("edit", object)'),
+ new Delete(security: 'is_granted("delete", object)'),
+ ],
+ normalizationContext: ['groups' => ['manufacturer:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
+ denormalizationContext: ['groups' => ['manufacturer:write', 'company:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
+)]
+#[ApiResource(
+ uriTemplate: '/manufacturers/{id}/children.{_format}',
+ operations: [
+ new GetCollection(
+ openapi: new Operation(summary: 'Retrieves the children elements of a manufacturer.'),
+ security: 'is_granted("@manufacturers.read")'
+ )
+ ],
+ uriVariables: [
+ 'id' => new Link(fromProperty: 'children', fromClass: Manufacturer::class)
+ ],
+ normalizationContext: ['groups' => ['manufacturer:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
+)]
+#[ApiFilter(PropertyFilter::class)]
+#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
+#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
+#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class Manufacturer extends AbstractCompany
{
- /**
- * @ORM\ManyToOne(targetEntity="Manufacturer", inversedBy="children")
- * @ORM\JoinColumn(name="parent_id", referencedColumnName="id")
- */
- protected $parent;
+ #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
+ #[ORM\JoinColumn(name: 'parent_id')]
+ #[Groups(['manufacturer:read', 'manufacturer:write'])]
+ #[ApiProperty(readableLink: false, writableLink: false)]
+ protected ?AbstractStructuralDBElement $parent = null;
- /**
- * @ORM\OneToMany(targetEntity="Manufacturer", mappedBy="parent")
- * @ORM\OrderBy({"name" = "ASC"})
- * @var Collection
- */
- protected $children;
+ #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ protected Collection $children;
/**
* @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Attachments\ManufacturerAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"name" = "ASC"})
- * @Assert\Valid()
*/
- protected $attachments;
+ #[Assert\Valid]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: ManufacturerAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ #[Groups(['manufacturer:read', 'manufacturer:write'])]
+ #[ApiProperty(readableLink: false, writableLink: true)]
+ protected Collection $attachments;
+
+ #[ORM\ManyToOne(targetEntity: ManufacturerAttachment::class)]
+ #[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
+ #[Groups(['manufacturer:read', 'manufacturer:write'])]
+ #[ApiProperty(readableLink: false, writableLink: true)]
+ protected ?Attachment $master_picture_attachment = null;
/** @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Parameters\ManufacturerParameter", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"group" = "ASC" ,"name" = "ASC"})
- * @Assert\Valid()
*/
- protected $parameters;
+ #[Assert\Valid]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: ManufacturerParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
+ #[Groups(['manufacturer:read', 'manufacturer:write'])]
+ #[ApiProperty(readableLink: false, writableLink: true)]
+ protected Collection $parameters;
+ public function __construct()
+ {
+ parent::__construct();
+ $this->children = new ArrayCollection();
+ $this->attachments = new ArrayCollection();
+ $this->parameters = new ArrayCollection();
+ }
}
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/MeasurementUnit.php b/src/Entity/Parts/MeasurementUnit.php
index 1110f287..6dd0b9f2 100644
--- a/src/Entity/Parts/MeasurementUnit.php
+++ b/src/Entity/Parts/MeasurementUnit.php
@@ -22,77 +22,144 @@ declare(strict_types=1);
namespace App\Entity\Parts;
+use Doctrine\Common\Collections\Criteria;
+use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
+use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
+use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Link;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\OpenApi\Model\Operation;
+use ApiPlatform\Serializer\Filter\PropertyFilter;
+use App\ApiPlatform\Filter\LikeFilter;
+use App\Entity\Attachments\Attachment;
+use App\Repository\Parts\MeasurementUnitRepository;
+use Doctrine\DBAL\Types\Types;
+use App\Entity\Base\AbstractStructuralDBElement;
+use Doctrine\Common\Collections\ArrayCollection;
use App\Entity\Attachments\MeasurementUnitAttachment;
use App\Entity\Base\AbstractPartsContainingDBElement;
use App\Entity\Parameters\MeasurementUnitParameter;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
+use Symfony\Component\Validator\Constraints\Length;
/**
* This unit represents the unit in which the amount of parts in stock are measured.
* This could be something like N, grams, meters, etc...
*
- * @ORM\Entity(repositoryClass="App\Repository\Parts\MeasurementUnitRepository")
- * @ORM\Table(name="`measurement_units`", indexes={
- * @ORM\Index(name="unit_idx_name", columns={"name"}),
- * @ORM\Index(name="unit_idx_parent_name", columns={"parent_id", "name"}),
- * })
- * @UniqueEntity("unit")
+ * @extends AbstractPartsContainingDBElement
*/
+#[UniqueEntity('unit')]
+#[ORM\Entity(repositoryClass: MeasurementUnitRepository::class)]
+#[ORM\Table(name: '`measurement_units`')]
+#[ORM\Index(columns: ['name'], name: 'unit_idx_name')]
+#[ORM\Index(columns: ['parent_id', 'name'], name: 'unit_idx_parent_name')]
+#[ApiResource(
+ operations: [
+ new Get(security: 'is_granted("read", object)'),
+ new GetCollection(security: 'is_granted("@measurement_units.read")'),
+ new Post(securityPostDenormalize: 'is_granted("create", object)'),
+ new Patch(security: 'is_granted("edit", object)'),
+ new Delete(security: 'is_granted("delete", object)'),
+ ],
+ normalizationContext: ['groups' => ['measurement_unit:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
+ denormalizationContext: ['groups' => ['measurement_unit:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
+)]
+#[ApiResource(
+ uriTemplate: '/measurement_units/{id}/children.{_format}',
+ operations: [
+ new GetCollection(
+ openapi: new Operation(summary: 'Retrieves the children elements of a MeasurementUnit.'),
+ security: 'is_granted("@measurement_units.read")'
+ )
+ ],
+ uriVariables: [
+ 'id' => new Link(fromProperty: 'children', fromClass: MeasurementUnit::class)
+ ],
+ normalizationContext: ['groups' => ['measurement_unit:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
+)]
+#[ApiFilter(PropertyFilter::class)]
+#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "unit"])]
+#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
+#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class MeasurementUnit extends AbstractPartsContainingDBElement
{
/**
* @var string The unit symbol that should be used for the Unit. This could be something like "", g (for grams)
* or m (for meters).
- * @ORM\Column(type="string", name="unit", nullable=true)
- * @Assert\Length(max=10)
*/
+ #[Assert\Length(max: 10)]
+ #[Groups(['simple', 'extended', 'full', 'import', 'measurement_unit:read', 'measurement_unit:write'])]
+ #[ORM\Column(name: 'unit', type: Types::STRING, nullable: true)]
protected ?string $unit = null;
+ #[Groups(['measurement_unit:read', 'measurement_unit:write'])]
+ protected string $comment = '';
+
/**
* @var bool Determines if the amount value associated with this unit should be treated as integer.
* Set to false, to measure continuous sizes likes masses or lengths.
- * @ORM\Column(type="boolean", name="is_integer")
*/
+ #[Groups(['simple', 'extended', 'full', 'import', 'measurement_unit:read', 'measurement_unit:write'])]
+ #[ORM\Column(name: 'is_integer', type: Types::BOOLEAN)]
protected bool $is_integer = false;
/**
* @var bool Determines if the unit can be used with SI Prefixes (kilo, giga, milli, etc.).
* Useful for sizes like meters. For this the unit must be set
- * @ORM\Column(type="boolean", name="use_si_prefix")
- * @Assert\Expression("this.isUseSIPrefix() == false or this.getUnit() != null", message="validator.measurement_unit.use_si_prefix_needs_unit")
*/
+ #[Assert\Expression('this.isUseSIPrefix() == false or this.getUnit() != null', message: 'validator.measurement_unit.use_si_prefix_needs_unit')]
+ #[Groups(['simple', 'full', 'import', 'measurement_unit:read', 'measurement_unit:write'])]
+ #[ORM\Column(name: 'use_si_prefix', type: Types::BOOLEAN)]
protected bool $use_si_prefix = false;
- /**
- * @ORM\OneToMany(targetEntity="MeasurementUnit", mappedBy="parent", cascade={"persist"})
- * @ORM\OrderBy({"name" = "ASC"})
- * @var Collection
- */
- protected $children;
+ #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class, cascade: ['persist'])]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ protected Collection $children;
- /**
- * @ORM\ManyToOne(targetEntity="MeasurementUnit", inversedBy="children")
- * @ORM\JoinColumn(name="parent_id", referencedColumnName="id")
- */
- protected $parent;
+ #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
+ #[ORM\JoinColumn(name: 'parent_id')]
+ #[Groups(['measurement_unit:read', 'measurement_unit:write'])]
+ #[ApiProperty(readableLink: false, writableLink: false)]
+ protected ?AbstractStructuralDBElement $parent = null;
/**
* @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Attachments\MeasurementUnitAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"name" = "ASC"})
- * @Assert\Valid()
*/
- protected $attachments;
+ #[Assert\Valid]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: MeasurementUnitAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ #[Groups(['measurement_unit:read', 'measurement_unit:write'])]
+ protected Collection $attachments;
+
+ #[ORM\ManyToOne(targetEntity: MeasurementUnitAttachment::class)]
+ #[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
+ #[Groups(['measurement_unit:read', 'measurement_unit:write'])]
+ protected ?Attachment $master_picture_attachment = null;
/** @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Parameters\MeasurementUnitParameter", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"group" = "ASC" ,"name" = "ASC"})
- * @Assert\Valid()
*/
- protected $parameters;
+ #[Assert\Valid]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: MeasurementUnitParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
+ #[Groups(['measurement_unit:read', 'measurement_unit:write'])]
+ protected Collection $parameters;
+
+ #[Groups(['measurement_unit:read'])]
+ protected ?\DateTimeImmutable $addedDate = null;
+ #[Groups(['measurement_unit:read'])]
+ protected ?\DateTimeImmutable $lastModified = null;
+
/**
* @return string
@@ -102,11 +169,6 @@ class MeasurementUnit extends AbstractPartsContainingDBElement
return $this->unit;
}
- /**
- * @param string|null $unit
- *
- * @return MeasurementUnit
- */
public function setUnit(?string $unit): self
{
$this->unit = $unit;
@@ -119,9 +181,6 @@ class MeasurementUnit extends AbstractPartsContainingDBElement
return $this->is_integer;
}
- /**
- * @return MeasurementUnit
- */
public function setIsInteger(bool $isInteger): self
{
$this->is_integer = $isInteger;
@@ -134,13 +193,17 @@ class MeasurementUnit extends AbstractPartsContainingDBElement
return $this->use_si_prefix;
}
- /**
- * @return MeasurementUnit
- */
public function setUseSIPrefix(bool $usesSIPrefixes): self
{
$this->use_si_prefix = $usesSIPrefixes;
return $this;
}
+ public function __construct()
+ {
+ parent::__construct();
+ $this->children = new ArrayCollection();
+ $this->attachments = new ArrayCollection();
+ $this->parameters = new ArrayCollection();
+ }
}
diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php
index bcd87e77..14a7903f 100644
--- a/src/Entity/Parts/Part.php
+++ b/src/Entity/Parts/Part.php
@@ -22,24 +22,46 @@ declare(strict_types=1);
namespace App\Entity\Parts;
+use App\ApiPlatform\Filter\TagFilter;
+use Doctrine\Common\Collections\Criteria;
+use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
+use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
+use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
+use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
+use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\Serializer\Filter\PropertyFilter;
+use App\ApiPlatform\Filter\EntityFilter;
+use App\ApiPlatform\Filter\LikeFilter;
+use App\ApiPlatform\Filter\PartStoragelocationFilter;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\PartAttachment;
-use App\Entity\Parts\PartTraits\ProjectTrait;
-use App\Entity\ProjectSystem\Project;
+use App\Entity\EDA\EDAPartInfo;
use App\Entity\Parameters\ParametersTrait;
use App\Entity\Parameters\PartParameter;
use App\Entity\Parts\PartTraits\AdvancedPropertyTrait;
+use App\Entity\Parts\PartTraits\AssociationTrait;
use App\Entity\Parts\PartTraits\BasicPropertyTrait;
+use App\Entity\Parts\PartTraits\EDATrait;
use App\Entity\Parts\PartTraits\InstockTrait;
use App\Entity\Parts\PartTraits\ManufacturerTrait;
use App\Entity\Parts\PartTraits\OrderTrait;
-use App\Entity\ProjectSystem\ProjectBOMEntry;
-use DateTime;
+use App\Entity\Parts\PartTraits\ProjectTrait;
+use App\EntityListeners\TreeCacheInvalidationListener;
+use App\Repository\PartRepository;
+use App\Validator\Constraints\UniqueObjectCollection;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -47,16 +69,41 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* Part class.
*
* The class properties are split over various traits in directory PartTraits.
- * Otherwise this class would be too big, to be maintained.
- *
- * @ORM\Entity(repositoryClass="App\Repository\PartRepository")
- * @ORM\Table("`parts`", indexes={
- * @ORM\Index(name="parts_idx_datet_name_last_id_needs", columns={"datetime_added", "name", "last_modified", "id", "needs_review"}),
- * @ORM\Index(name="parts_idx_name", columns={"name"}),
- * @ORM\Index(name="parts_idx_ipn", columns={"ipn"}),
- * })
- * @UniqueEntity(fields={"ipn"}, message="part.ipn.must_be_unique")
+ * Otherwise, this class would be too big, to be maintained.
+ * @see \App\Tests\Entity\Parts\PartTest
+ * @extends AttachmentContainingDBElement
+ * @template-use ParametersTrait
*/
+#[UniqueEntity(fields: ['ipn'], message: 'part.ipn.must_be_unique')]
+#[ORM\Entity(repositoryClass: PartRepository::class)]
+#[ORM\EntityListeners([TreeCacheInvalidationListener::class])]
+#[ORM\Table('`parts`')]
+#[ORM\Index(columns: ['datetime_added', 'name', 'last_modified', 'id', 'needs_review'], name: 'parts_idx_datet_name_last_id_needs')]
+#[ORM\Index(columns: ['name'], name: 'parts_idx_name')]
+#[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')]
+#[ApiResource(
+ operations: [
+ new Get(normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read',
+ 'orderdetail:read', 'pricedetail:read', 'parameter:read', 'attachment:read', 'eda_info:read'],
+ 'openapi_definition_name' => 'Read',
+ ], security: 'is_granted("read", object)'),
+ new GetCollection(security: 'is_granted("@parts.read")'),
+ new Post(securityPostDenormalize: 'is_granted("create", object)'),
+ new Patch(security: 'is_granted("edit", object)'),
+ new Delete(security: 'is_granted("delete", object)'),
+ ],
+ normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'],
+ denormalizationContext: ['groups' => ['part:write', 'api:basic:write', 'eda_info:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
+)]
+#[ApiFilter(PropertyFilter::class)]
+#[ApiFilter(EntityFilter::class, properties: ["category", "footprint", "manufacturer", "partUnit"])]
+#[ApiFilter(PartStoragelocationFilter::class, properties: ["storage_location"])]
+#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "description", "ipn", "manufacturer_product_number"])]
+#[ApiFilter(TagFilter::class, properties: ["tags"])]
+#[ApiFilter(BooleanFilter::class, properties: ["favorite" , "needs_review"])]
+#[ApiFilter(RangeFilter::class, properties: ["mass", "minamount"])]
+#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
+#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class Part extends AttachmentContainingDBElement
{
use AdvancedPropertyTrait;
@@ -67,18 +114,18 @@ class Part extends AttachmentContainingDBElement
use OrderTrait;
use ParametersTrait;
use ProjectTrait;
+ use AssociationTrait;
+ use EDATrait;
/** @var Collection
- * @Assert\Valid()
- * @ORM\OneToMany(targetEntity="App\Entity\Parameters\PartParameter", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"group" = "ASC" ,"name" = "ASC"})
*/
- protected $parameters;
+ #[Assert\Valid]
+ #[Groups(['full', 'part:read', 'part:write', 'import'])]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: PartParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
+ #[UniqueObjectCollection(fields: ['name', 'group', 'element'])]
+ protected Collection $parameters;
- /**
- * @ORM\Column(type="datetime", name="datetime_added", options={"default"="CURRENT_TIMESTAMP"})
- */
- protected ?DateTime $addedDate = null;
/** *************************************************************
* Overridden properties
@@ -87,39 +134,48 @@ class Part extends AttachmentContainingDBElement
/**
* @var string The name of this part
- * @ORM\Column(type="string")
*/
protected string $name = '';
/**
* @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Attachments\PartAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"name" = "ASC"})
- * @Assert\Valid()
*/
- protected $attachments;
+ #[Assert\Valid]
+ #[Groups(['full', 'part:read', 'part:write'])]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: PartAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ protected Collection $attachments;
/**
- * @var DateTime the date when this element was modified the last time
- * @ORM\Column(type="datetime", name="last_modified", options={"default"="CURRENT_TIMESTAMP"})
- */
- protected ?DateTime $lastModified = null;
-
- /**
- * @var Attachment
- * @ORM\ManyToOne(targetEntity="App\Entity\Attachments\Attachment")
- * @ORM\JoinColumn(name="id_preview_attachement", referencedColumnName="id")
- * @Assert\Expression("value == null or value.isPicture()", message="part.master_attachment.must_be_picture")
+ * @var Attachment|null
*/
+ #[Assert\Expression('value == null or value.isPicture()', message: 'part.master_attachment.must_be_picture')]
+ #[ORM\ManyToOne(targetEntity: PartAttachment::class)]
+ #[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
+ #[Groups(['part:read', 'part:write'])]
protected ?Attachment $master_picture_attachment = null;
+ #[Groups(['part:read'])]
+ protected ?\DateTimeImmutable $addedDate = null;
+ #[Groups(['part:read'])]
+ protected ?\DateTimeImmutable $lastModified = null;
+
+
public function __construct()
{
+ $this->attachments = new ArrayCollection();
parent::__construct();
$this->partLots = new ArrayCollection();
$this->orderdetails = new ArrayCollection();
$this->parameters = new ArrayCollection();
$this->project_bom_entries = new ArrayCollection();
+
+ $this->associated_parts_as_owner = new ArrayCollection();
+ $this->associated_parts_as_other = new ArrayCollection();
+
+ //By default, the part has no provider
+ $this->providerReference = InfoProviderReference::noProvider();
+ $this->eda_info = new EDAPartInfo();
}
public function __clone()
@@ -145,25 +201,32 @@ class Part extends AttachmentContainingDBElement
foreach ($parameters as $parameter) {
$this->addParameter(clone $parameter);
}
+
+ //Deep clone the owned part associations (the owned ones make not much sense without the owner)
+ $ownedAssociations = $this->associated_parts_as_owner;
+ $this->associated_parts_as_owner = new ArrayCollection();
+ foreach ($ownedAssociations as $association) {
+ $this->addAssociatedPartsAsOwner(clone $association);
+ }
+
+ //Deep clone info provider
+ $this->providerReference = clone $this->providerReference;
+ $this->eda_info = clone $this->eda_info;
}
parent::__clone();
}
- /**
- * @Assert\Callback
- */
- public function validate(ExecutionContextInterface $context, $payload)
+ #[Assert\Callback]
+ public function validate(ExecutionContextInterface $context, $payload): void
{
//Ensure that the part name fullfills the regex of the category
- if ($this->category) {
+ if ($this->category instanceof Category) {
$regex = $this->category->getPartnameRegex();
- if (!empty($regex)) {
- if (!preg_match($regex, $this->name)) {
- $context->buildViolation('part.name.must_match_category_regex')
- ->atPath('name')
- ->setParameter('%regex%', $regex)
- ->addViolation();
- }
+ if ($regex !== '' && !preg_match($regex, $this->name)) {
+ $context->buildViolation('part.name.must_match_category_regex')
+ ->atPath('name')
+ ->setParameter('%regex%', $regex)
+ ->addViolation();
}
}
}
diff --git a/src/Entity/Parts/PartAssociation.php b/src/Entity/Parts/PartAssociation.php
new file mode 100644
index 00000000..32017488
--- /dev/null
+++ b/src/Entity/Parts/PartAssociation.php
@@ -0,0 +1,235 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Entity\Parts;
+
+use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
+use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
+use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\Serializer\Filter\PropertyFilter;
+use App\ApiPlatform\Filter\LikeFilter;
+use App\Entity\Contracts\TimeStampableInterface;
+use App\Repository\DBElementRepository;
+use Doctrine\DBAL\Types\Types;
+use Doctrine\ORM\Mapping as ORM;
+use App\Entity\Base\AbstractDBElement;
+use App\Entity\Base\TimestampTrait;
+use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Validator\Constraints as Assert;
+use Symfony\Component\Validator\Constraints\Length;
+
+/**
+ * This entity describes a part association, which is a semantic connection between two parts.
+ * For example, a part association can be used to describe that a part is a replacement for another part.
+ * @see \App\Tests\Entity\Parts\PartAssociationTest
+ */
+#[ORM\Entity(repositoryClass: DBElementRepository::class)]
+#[ORM\HasLifecycleCallbacks]
+#[UniqueEntity(fields: ['other', 'owner', 'type'], message: 'validator.part_association.already_exists')]
+#[ApiResource(
+ operations: [
+ new Get(security: 'is_granted("read", object)'),
+ new GetCollection(security: 'is_granted("@parts.read")'),
+ new Post(securityPostDenormalize: 'is_granted("create", object)'),
+ new Patch(security: 'is_granted("edit", object)'),
+ new Delete(security: 'is_granted("delete", object)'),
+ ],
+ normalizationContext: ['groups' => ['part_assoc:read', 'part_assoc:read:standalone', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
+ denormalizationContext: ['groups' => ['part_assoc:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
+)]
+#[ApiFilter(PropertyFilter::class)]
+#[ApiFilter(LikeFilter::class, properties: ["other_type", "comment"])]
+#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
+#[ApiFilter(OrderFilter::class, properties: ['comment', 'addedDate', 'lastModified'])]
+class PartAssociation extends AbstractDBElement implements TimeStampableInterface
+{
+ use TimestampTrait;
+
+ /**
+ * @var AssociationType The type of this association (how the two parts are related)
+ */
+ #[ORM\Column(type: Types::SMALLINT, enumType: AssociationType::class)]
+ #[Groups(['part_assoc:read', 'part_assoc:write'])]
+ protected AssociationType $type = AssociationType::OTHER;
+
+ /**
+ * @var string|null A user definable association type, which can be described in the comment field, which
+ * is used if the type is OTHER
+ */
+ #[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
+ #[Assert\Expression("this.getType().value !== 0 or this.getOtherType() !== null",
+ message: 'validator.part_association.must_set_an_value_if_type_is_other')]
+ #[Groups(['part_assoc:read', 'part_assoc:write'])]
+ #[Length(max: 255)]
+ protected ?string $other_type = null;
+
+ /**
+ * @var string|null A comment describing this association further.
+ */
+ #[ORM\Column(type: Types::TEXT, nullable: true)]
+ #[Groups(['part_assoc:read', 'part_assoc:write'])]
+ protected ?string $comment = null;
+
+ /**
+ * @var Part|null The part which "owns" this association, e.g. the part which is a replacement for another part
+ */
+ #[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'associated_parts_as_owner')]
+ #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
+ #[Assert\NotNull]
+ #[Groups(['part_assoc:read:standalone', 'part_assoc:write'])]
+ protected ?Part $owner = null;
+
+ /**
+ * @var Part|null The part which is "owned" by this association, e.g. the part which is replaced by another part
+ */
+ #[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'associated_parts_as_other')]
+ #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
+ #[Assert\NotNull]
+ #[Assert\Expression("this.getOwner() !== this.getOther()",
+ message: 'validator.part_association.part_cannot_be_associated_with_itself')]
+ #[Groups(['part_assoc:read', 'part_assoc:write'])]
+ protected ?Part $other = null;
+
+ /**
+ * Returns the (semantic) relation type of this association as an AssociationType enum value.
+ * If the type is set to OTHER, then the other_type field value is used for the user defined type.
+ * @return AssociationType
+ */
+ public function getType(): AssociationType
+ {
+ return $this->type;
+ }
+
+ /**
+ * Sets the (semantic) relation type of this association as an AssociationType enum value.
+ * @param AssociationType $type
+ * @return $this
+ */
+ public function setType(AssociationType $type): PartAssociation
+ {
+ $this->type = $type;
+ return $this;
+ }
+
+ /**
+ * Returns a comment, which describes this association further.
+ * @return string|null
+ */
+ public function getComment(): ?string
+ {
+ return $this->comment;
+ }
+
+ /**
+ * Sets a comment, which describes this association further.
+ * @param string|null $comment
+ * @return $this
+ */
+ public function setComment(?string $comment): PartAssociation
+ {
+ $this->comment = $comment;
+ return $this;
+ }
+
+ /**
+ * Returns the part which "owns" this association, e.g. the part which is a replacement for another part.
+ * @return Part|null
+ */
+ public function getOwner(): ?Part
+ {
+ return $this->owner;
+ }
+
+ /**
+ * Sets the part which "owns" this association, e.g. the part which is a replacement for another part.
+ * @param Part|null $owner
+ * @return $this
+ */
+ public function setOwner(?Part $owner): PartAssociation
+ {
+ $this->owner = $owner;
+ return $this;
+ }
+
+ /**
+ * Returns the part which is "owned" by this association, e.g. the part which is replaced by another part.
+ * @return Part|null
+ */
+ public function getOther(): ?Part
+ {
+ return $this->other;
+ }
+
+ /**
+ * Sets the part which is "owned" by this association, e.g. the part which is replaced by another part.
+ * @param Part|null $other
+ * @return $this
+ */
+ public function setOther(?Part $other): PartAssociation
+ {
+ $this->other = $other;
+ return $this;
+ }
+
+ /**
+ * Returns the user defined association type, which is used if the type is set to OTHER.
+ * @return string|null
+ */
+ public function getOtherType(): ?string
+ {
+ return $this->other_type;
+ }
+
+ /**
+ * Sets the user defined association type, which is used if the type is set to OTHER.
+ * @param string|null $other_type
+ * @return $this
+ */
+ public function setOtherType(?string $other_type): PartAssociation
+ {
+ $this->other_type = $other_type;
+ return $this;
+ }
+
+ /**
+ * Returns the translation key for the type of this association.
+ * If the type is set to OTHER, then the other_type field value is used.
+ * @return string
+ */
+ public function getTypeTranslationKey(): string
+ {
+ if ($this->type === AssociationType::OTHER) {
+ return $this->other_type ?? 'Unknown';
+ }
+ return $this->type->getTranslationKey();
+ }
+
+}
\ No newline at end of file
diff --git a/src/Entity/Parts/PartLot.php b/src/Entity/Parts/PartLot.php
index 1201f254..d893e6de 100644
--- a/src/Entity/Parts/PartLot.php
+++ b/src/Entity/Parts/PartLot.php
@@ -22,86 +22,154 @@ declare(strict_types=1);
namespace App\Entity\Parts;
+use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
+use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
+use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
+use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
+use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\Serializer\Filter\PropertyFilter;
+use App\ApiPlatform\Filter\LikeFilter;
+use App\Validator\Constraints\Year2038BugWorkaround;
+use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\TimestampTrait;
use App\Entity\Contracts\NamedElementInterface;
use App\Entity\Contracts\TimeStampableInterface;
+use App\Entity\UserSystem\User;
use App\Validator\Constraints\Selectable;
use App\Validator\Constraints\ValidPartLot;
use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Exception;
+use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
+use Symfony\Component\Validator\Constraints\Length;
+use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* This entity describes a lot where parts can be stored.
* It is the connection between a part and its store locations.
*
- * @ORM\Entity()
- * @ORM\Table(name="part_lots", indexes={
- * @ORM\Index(name="part_lots_idx_instock_un_expiration_id_part", columns={"instock_unknown", "expiration_date", "id_part"}),
- * @ORM\Index(name="part_lots_idx_needs_refill", columns={"needs_refill"}),
- * })
- * @ORM\HasLifecycleCallbacks()
- * @ValidPartLot()
+ * @see \App\Tests\Entity\Parts\PartLotTest
*/
+#[ORM\Entity]
+#[ORM\HasLifecycleCallbacks]
+#[ORM\Table(name: 'part_lots')]
+#[ORM\Index(columns: ['instock_unknown', 'expiration_date', 'id_part'], name: 'part_lots_idx_instock_un_expiration_id_part')]
+#[ORM\Index(columns: ['needs_refill'], name: 'part_lots_idx_needs_refill')]
+#[ORM\Index(columns: ['vendor_barcode'], name: 'part_lots_idx_barcode')]
+#[ValidPartLot]
+#[UniqueEntity(['user_barcode'], message: 'validator.part_lot.vendor_barcode_must_be_unique')]
+#[ApiResource(
+ operations: [
+ new Get(security: 'is_granted("read", object)'),
+ new GetCollection(security: 'is_granted("@parts.read")'),
+ new Post(securityPostDenormalize: 'is_granted("create", object)'),
+ new Patch(security: 'is_granted("edit", object)'),
+ new Delete(security: 'is_granted("delete", object)'),
+ ],
+ normalizationContext: ['groups' => ['part_lot:read', 'part_lot:read:standalone', 'api:basic:read', 'pricedetail:read'], 'openapi_definition_name' => 'Read'],
+ denormalizationContext: ['groups' => ['part_lot:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
+)]
+#[ApiFilter(PropertyFilter::class)]
+#[ApiFilter(LikeFilter::class, properties: ["description", "comment"])]
+#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
+#[ApiFilter(BooleanFilter::class, properties: ['instock_unknown', 'needs_refill'])]
+#[ApiFilter(RangeFilter::class, properties: ['amount'])]
+#[ApiFilter(OrderFilter::class, properties: ['description', 'comment', 'addedDate', 'lastModified'])]
class PartLot extends AbstractDBElement implements TimeStampableInterface, NamedElementInterface
{
use TimestampTrait;
/**
* @var string A short description about this lot, shown in table
- * @ORM\Column(type="text")
*/
+ #[Groups(['simple', 'extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])]
+ #[ORM\Column(type: Types::TEXT)]
protected string $description = '';
/**
* @var string a comment stored with this lot
- * @ORM\Column(type="text")
*/
+ #[Groups(['full', 'import', 'part_lot:read', 'part_lot:write'])]
+ #[ORM\Column(type: Types::TEXT)]
protected string $comment = '';
/**
- * @var ?DateTime Set a time until when the lot must be used.
+ * @var \DateTimeImmutable|null Set a time until when the lot must be used.
* Set to null, if the lot can be used indefinitely.
- * @ORM\Column(type="datetime", name="expiration_date", nullable=true)
*/
- protected ?DateTime $expiration_date = null;
+ #[Groups(['extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])]
+ #[ORM\Column(name: 'expiration_date', type: Types::DATETIME_IMMUTABLE, nullable: true)]
+ #[Year2038BugWorkaround]
+ protected ?\DateTimeImmutable $expiration_date = null;
/**
- * @var Storelocation|null The storelocation of this lot
- * @ORM\ManyToOne(targetEntity="Storelocation")
- * @ORM\JoinColumn(name="id_store_location", referencedColumnName="id", nullable=true)
- * @Selectable()
+ * @var StorageLocation|null The storelocation of this lot
*/
- protected ?Storelocation $storage_location = null;
+ #[Groups(['simple', 'extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])]
+ #[ORM\ManyToOne(targetEntity: StorageLocation::class, fetch: 'EAGER')]
+ #[ORM\JoinColumn(name: 'id_store_location')]
+ #[Selectable]
+ protected ?StorageLocation $storage_location = null;
/**
* @var bool If this is set to true, the instock amount is marked as not known
- * @ORM\Column(type="boolean")
*/
+ #[Groups(['simple', 'extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])]
+ #[ORM\Column(type: Types::BOOLEAN)]
protected bool $instock_unknown = false;
/**
- * @var float For continuous sizes (length, volume, etc.) the instock is saved here.
- * @ORM\Column(type="float")
- * @Assert\PositiveOrZero()
+ * @var float The amount of parts in this lot. For integer-quantities this value is rounded to the next integer.
*/
+ #[Assert\PositiveOrZero]
+ #[Groups(['simple', 'extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])]
+ #[ORM\Column(type: Types::FLOAT)]
protected float $amount = 0.0;
/**
* @var bool determines if this lot was manually marked for refilling
- * @ORM\Column(type="boolean")
*/
+ #[Groups(['extended', 'full', 'import', 'part_lot:read', 'part_lot:write'])]
+ #[ORM\Column(type: Types::BOOLEAN)]
protected bool $needs_refill = false;
/**
- * @var Part The part that is stored in this lot
- * @ORM\ManyToOne(targetEntity="Part", inversedBy="partLots")
- * @ORM\JoinColumn(name="id_part", referencedColumnName="id", nullable=false, onDelete="CASCADE")
- * @Assert\NotNull()
+ * @var Part|null The part that is stored in this lot
*/
- protected Part $part;
+ #[Assert\NotNull]
+ #[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'partLots')]
+ #[ORM\JoinColumn(name: 'id_part', nullable: false, onDelete: 'CASCADE')]
+ #[Groups(['part_lot:read:standalone', 'part_lot:write'])]
+ #[ApiProperty(writableLink: false)]
+ protected ?Part $part = null;
+
+ /**
+ * @var User|null The owner of this part lot
+ */
+ #[ORM\ManyToOne(targetEntity: User::class)]
+ #[ORM\JoinColumn(name: 'id_owner', onDelete: 'SET NULL')]
+ #[Groups(['part_lot:read', 'part_lot:write'])]
+ #[ApiProperty(writableLink: false)]
+ protected ?User $owner = null;
+
+ /**
+ * @var string|null The content of the barcode of this part lot (e.g. a barcode on the package put by the vendor)
+ */
+ #[ORM\Column(name: "vendor_barcode", type: Types::STRING, nullable: true)]
+ #[Groups(['part_lot:read', 'part_lot:write'])]
+ #[Length(max: 255)]
+ protected ?string $user_barcode = null;
public function __clone()
{
@@ -113,20 +181,19 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
/**
* Check if the current part lot is expired.
- * This is the case, if the expiration date is greater the the current date.
+ * This is the case, if the expiration date is greater the current date.
*
* @return bool|null True, if the part lot is expired. Returns null, if no expiration date was set.
*
- * @throws Exception If an error with the DateTime occurs
*/
public function isExpired(): ?bool
{
- if (null === $this->expiration_date) {
+ if (!$this->expiration_date instanceof \DateTimeInterface) {
return null;
}
//Check if the expiration date is bigger then current time
- return $this->expiration_date < new DateTime('now');
+ return $this->expiration_date < new \DateTimeImmutable('now');
}
/**
@@ -139,8 +206,6 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
/**
* Sets the description of the part lot.
- *
- * @return PartLot
*/
public function setDescription(string $description): self
{
@@ -159,8 +224,6 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
/**
* Sets the comment for this part lot.
- *
- * @return PartLot
*/
public function setComment(string $comment): self
{
@@ -172,19 +235,17 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
/**
* Gets the expiration date for the part lot. Returns null, if no expiration date was set.
*/
- public function getExpirationDate(): ?DateTime
+ public function getExpirationDate(): ?\DateTimeImmutable
{
return $this->expiration_date;
}
/**
- * Sets the expiration date for the part lot. Set to null, if the part lot does not expires.
+ * Sets the expiration date for the part lot. Set to null, if the part lot does not expire.
*
- * @param DateTime|null $expiration_date
*
- * @return PartLot
*/
- public function setExpirationDate(?DateTime $expiration_date): self
+ public function setExpirationDate(?\DateTimeImmutable $expiration_date): self
{
$this->expiration_date = $expiration_date;
@@ -194,19 +255,17 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
/**
* Gets the storage location, where this part lot is stored.
*
- * @return Storelocation|null The store location where this part is stored
+ * @return StorageLocation|null The store location where this part is stored
*/
- public function getStorageLocation(): ?Storelocation
+ public function getStorageLocation(): ?StorageLocation
{
return $this->storage_location;
}
/**
* Sets the storage location, where this part lot is stored.
- *
- * @return PartLot
*/
- public function setStorageLocation(?Storelocation $storage_location): self
+ public function setStorageLocation(?StorageLocation $storage_location): self
{
$this->storage_location = $storage_location;
@@ -216,15 +275,13 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
/**
* Return the part that is stored in this part lot.
*/
- public function getPart(): Part
+ public function getPart(): ?Part
{
return $this->part;
}
/**
* Sets the part that is stored in this part lot.
- *
- * @return PartLot
*/
public function setPart(Part $part): self
{
@@ -243,8 +300,6 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
/**
* Set the unknown instock status of this part lot.
- *
- * @return PartLot
*/
public function setInstockUnknown(bool $instock_unknown): self
{
@@ -286,9 +341,6 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
return $this->needs_refill;
}
- /**
- * @return PartLot
- */
public function setNeedsRefill(bool $needs_refill): self
{
$this->needs_refill = $needs_refill;
@@ -296,8 +348,69 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
return $this;
}
+ /**
+ * Returns the owner of this part lot.
+ */
+ public function getOwner(): ?User
+ {
+ return $this->owner;
+ }
+
+ /**
+ * Sets the owner of this part lot.
+ */
+ public function setOwner(?User $owner): PartLot
+ {
+ $this->owner = $owner;
+ return $this;
+ }
+
public function getName(): string
{
return $this->description;
}
+
+ /**
+ * The content of the barcode of this part lot (e.g. a barcode on the package put by the vendor), or
+ * null if no barcode is set.
+ * @return string|null
+ */
+ public function getUserBarcode(): ?string
+ {
+ return $this->user_barcode;
+ }
+
+ /**
+ * Set the content of the barcode of this part lot (e.g. a barcode on the package put by the vendor).
+ * @param string|null $user_barcode
+ * @return $this
+ */
+ public function setUserBarcode(?string $user_barcode): PartLot
+ {
+ $this->user_barcode = $user_barcode;
+ return $this;
+ }
+
+
+
+ #[Assert\Callback]
+ public function validate(ExecutionContextInterface $context, $payload): void
+ {
+ //Ensure that the owner is not the anonymous user
+ if ($this->getOwner() && $this->getOwner()->isAnonymousUser()) {
+ $context->buildViolation('validator.part_lot.owner_must_not_be_anonymous')
+ ->atPath('owner')
+ ->addViolation();
+ }
+
+ //When the storage location sets the owner must match, the part lot owner must match the storage location owner
+ if ($this->getStorageLocation() && $this->getStorageLocation()->isPartOwnerMustMatch()
+ && $this->getStorageLocation()->getOwner() && $this->getOwner() && ($this->getOwner() !== $this->getStorageLocation()->getOwner()
+ && $this->owner->getID() !== $this->getStorageLocation()->getOwner()->getID())) {
+ $context->buildViolation('validator.part_lot.owner_must_match_storage_location_owner')
+ ->setParameter('%owner_name%', $this->getStorageLocation()->getOwner()->getFullName(true))
+ ->atPath('owner')
+ ->addViolation();
+ }
+ }
}
diff --git a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php
index a798c305..230ba7b7 100644
--- a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php
+++ b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php
@@ -22,9 +22,13 @@ 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;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
+use Symfony\Component\Validator\Constraints\Length;
/**
* Advanced properties of a part, not related to a more specific group.
@@ -33,31 +37,42 @@ trait AdvancedPropertyTrait
{
/**
* @var bool Determines if this part entry needs review (for example, because it is work in progress)
- * @ORM\Column(type="boolean")
*/
+ #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
+ #[ORM\Column(type: Types::BOOLEAN)]
protected bool $needs_review = false;
/**
- * @var string a comma separated list of tags, associated with the part
- * @ORM\Column(type="text")
+ * @var string A comma separated list of tags, associated with the part
*/
+ #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
+ #[ORM\Column(type: Types::TEXT)]
protected string $tags = '';
/**
- * @var float|null how much a single part unit weighs in grams
- * @ORM\Column(type="float", nullable=true)
- * @Assert\PositiveOrZero()
+ * @var float|null How much a single part unit weighs in grams
*/
+ #[Assert\PositiveOrZero]
+ #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
+ #[ORM\Column(type: Types::FLOAT, nullable: true)]
protected ?float $mass = null;
/**
- * @var string The internal part number of the part
- * @ORM\Column(type="string", length=100, nullable=true, unique=true)
- * @Assert\Length(max="100")
- *
+ * @var string|null The internal part number of the part
*/
+ #[Assert\Length(max: 100)]
+ #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
+ #[ORM\Column(type: Types::STRING, length: 100, unique: true, nullable: true)]
+ #[Length(max: 100)]
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_')]
+ #[Groups(['full', 'part:read'])]
+ protected InfoProviderReference $providerReference;
+
/**
* Checks if this part is marked, for that it needs further review.
*/
@@ -138,7 +153,6 @@ trait AdvancedPropertyTrait
/**
* Sets the internal part number of the part
* @param string $ipn The new IPN of the part
- * @return Part
*/
public function setIpn(?string $ipn): Part
{
@@ -146,5 +160,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/AssociationTrait.php b/src/Entity/Parts/PartTraits/AssociationTrait.php
new file mode 100644
index 00000000..bb80fc5a
--- /dev/null
+++ b/src/Entity/Parts/PartTraits/AssociationTrait.php
@@ -0,0 +1,110 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Entity\Parts\PartTraits;
+
+use App\Entity\Parts\PartAssociation;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Validator\Constraints\Valid;
+use Doctrine\ORM\Mapping as ORM;
+
+trait AssociationTrait
+{
+ /**
+ * @var Collection All associations where this part is the owner
+ */
+ #[Valid]
+ #[ORM\OneToMany(mappedBy: 'owner', targetEntity: PartAssociation::class,
+ cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[Groups(['part:read', 'part:write', 'full'])]
+ protected Collection $associated_parts_as_owner;
+
+ /**
+ * @var Collection All associations where this part is the owned/other part
+ */
+ #[Valid]
+ #[ORM\OneToMany(mappedBy: 'other', targetEntity: PartAssociation::class,
+ cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[Groups(['part:read'])]
+ protected Collection $associated_parts_as_other;
+
+ /**
+ * Returns all associations where this part is the owner.
+ * @return Collection
+ */
+ public function getAssociatedPartsAsOwner(): Collection
+ {
+ return $this->associated_parts_as_owner;
+ }
+
+ /**
+ * Add a new association where this part is the owner.
+ * @param PartAssociation $association
+ * @return $this
+ */
+ public function addAssociatedPartsAsOwner(PartAssociation $association): self
+ {
+ //Ensure that the association is really owned by this part
+ $association->setOwner($this);
+
+ $this->associated_parts_as_owner->add($association);
+ return $this;
+ }
+
+ /**
+ * Remove an association where this part is the owner.
+ * @param PartAssociation $association
+ * @return $this
+ */
+ public function removeAssociatedPartsAsOwner(PartAssociation $association): self
+ {
+ $this->associated_parts_as_owner->removeElement($association);
+ return $this;
+ }
+
+ /**
+ * Returns all associations where this part is the owned/other part.
+ * If you want to modify the association, do it on the owning part
+ * @return Collection
+ */
+ public function getAssociatedPartsAsOther(): Collection
+ {
+ return $this->associated_parts_as_other;
+ }
+
+ /**
+ * Returns all associations where this part is the owned or other part.
+ * @return Collection
+ */
+ public function getAssociatedPartsAll(): Collection
+ {
+ return new ArrayCollection(
+ array_merge(
+ $this->associated_parts_as_owner->toArray(),
+ $this->associated_parts_as_other->toArray()
+ )
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/Entity/Parts/PartTraits/BasicPropertyTrait.php b/src/Entity/Parts/PartTraits/BasicPropertyTrait.php
index c7c7fe50..7e483ed2 100644
--- a/src/Entity/Parts/PartTraits/BasicPropertyTrait.php
+++ b/src/Entity/Parts/PartTraits/BasicPropertyTrait.php
@@ -22,54 +22,61 @@ declare(strict_types=1);
namespace App\Entity\Parts\PartTraits;
+use Doctrine\DBAL\Types\Types;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Validator\Constraints\Selectable;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
trait BasicPropertyTrait
{
/**
* @var string A text describing what this part does
- * @ORM\Column(type="text")
*/
+ #[Groups(['simple', 'extended', 'full', 'import', 'part:read', 'part:write'])]
+ #[ORM\Column(type: Types::TEXT)]
protected string $description = '';
/**
* @var string A comment/note related to this part
- * @ORM\Column(type="text")
*/
+ #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
+ #[ORM\Column(type: Types::TEXT)]
protected string $comment = '';
/**
- * @var bool Kept for compatibility (it is not used now, and I dont think it was used in old versions)
- * @ORM\Column(type="boolean")
+ * @var bool Kept for compatibility (it is not used now, and I don't think it was used in old versions)
*/
+ #[ORM\Column(type: Types::BOOLEAN)]
protected bool $visible = true;
/**
* @var bool true, if the part is marked as favorite
- * @ORM\Column(type="boolean")
*/
+ #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
+ #[ORM\Column(type: Types::BOOLEAN)]
protected bool $favorite = false;
/**
- * @var Category The category this part belongs too (e.g. Resistors). Use tags, for more complex grouping.
+ * @var Category|null The category this part belongs too (e.g. Resistors). Use tags, for more complex grouping.
* Every part must have a category.
- * @ORM\ManyToOne(targetEntity="Category")
- * @ORM\JoinColumn(name="id_category", referencedColumnName="id", nullable=false)
- * @Selectable()
- * @Assert\NotNull(message="validator.select_valid_category")
*/
+ #[Assert\NotNull(message: 'validator.select_valid_category')]
+ #[Selectable]
+ #[Groups(['simple', 'extended', 'full', 'import', "part:read", "part:write"])]
+ #[ORM\ManyToOne(targetEntity: Category::class)]
+ #[ORM\JoinColumn(name: 'id_category', nullable: false)]
protected ?Category $category = null;
/**
* @var Footprint|null The footprint of this part (e.g. DIP8)
- * @ORM\ManyToOne(targetEntity="Footprint")
- * @ORM\JoinColumn(name="id_footprint", referencedColumnName="id")
- * @Selectable()
*/
+ #[Groups(['simple', 'extended', 'full', 'import', 'part:read', 'part:write'])]
+ #[ORM\ManyToOne(targetEntity: Footprint::class)]
+ #[ORM\JoinColumn(name: 'id_footprint')]
+ #[Selectable]
protected ?Footprint $footprint = null;
/**
@@ -130,7 +137,7 @@ trait BasicPropertyTrait
/**
* Gets the Footprint of this part (e.g. DIP8).
*
- * @return Footprint|null The footprint of this part. Null if this part should no have a footprint.
+ * @return Footprint|null The footprint of this part. Null if this part should not have a footprint.
*/
public function getFootprint(): ?Footprint
{
diff --git a/src/Entity/Parts/PartTraits/EDATrait.php b/src/Entity/Parts/PartTraits/EDATrait.php
new file mode 100644
index 00000000..313552e7
--- /dev/null
+++ b/src/Entity/Parts/PartTraits/EDATrait.php
@@ -0,0 +1,53 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Entity\Parts\PartTraits;
+
+use App\Entity\EDA\EDAPartInfo;
+use Doctrine\ORM\Mapping\Embedded;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Validator\Constraints\Valid;
+
+trait EDATrait
+{
+ #[Valid]
+ #[Embedded(class: EDAPartInfo::class)]
+ #[Groups(['full', 'part:read', 'part:write', 'import'])]
+ protected EDAPartInfo $eda_info;
+
+ public function getEdaInfo(): EDAPartInfo
+ {
+ return $this->eda_info;
+ }
+
+ public function setEdaInfo(?EDAPartInfo $eda_info): self
+ {
+ if ($eda_info !== null) {
+ //Do a clone, to ensure that the property is updated in the database
+ $eda_info = clone $eda_info;
+ }
+
+ $this->eda_info = $eda_info;
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/src/Entity/Parts/PartTraits/InstockTrait.php b/src/Entity/Parts/PartTraits/InstockTrait.php
index b79719ec..08b070f3 100644
--- a/src/Entity/Parts/PartTraits/InstockTrait.php
+++ b/src/Entity/Parts/PartTraits/InstockTrait.php
@@ -22,10 +22,14 @@ declare(strict_types=1);
namespace App\Entity\Parts\PartTraits;
+use Doctrine\Common\Collections\Criteria;
+use Doctrine\DBAL\Types\Types;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\PartLot;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
/**
@@ -34,32 +38,34 @@ use Symfony\Component\Validator\Constraints as Assert;
trait InstockTrait
{
/**
- * @var Collection|PartLot[] A list of part lots where this part is stored
- * @ORM\OneToMany(targetEntity="PartLot", mappedBy="part", cascade={"persist", "remove"}, orphanRemoval=true)
- * @Assert\Valid()
- * @ORM\OrderBy({"amount" = "DESC"})
+ * @var Collection A list of part lots where this part is stored
*/
- protected $partLots;
+ #[Assert\Valid]
+ #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
+ #[ORM\OneToMany(mappedBy: 'part', targetEntity: PartLot::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['amount' => Criteria::DESC])]
+ protected Collection $partLots;
/**
* @var float The minimum amount of the part that has to be instock, otherwise more is ordered.
* Given in the partUnit.
- * @ORM\Column(type="float")
- * @Assert\PositiveOrZero()
*/
+ #[Assert\PositiveOrZero]
+ #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
+ #[ORM\Column(type: Types::FLOAT)]
protected float $minamount = 0;
/**
* @var ?MeasurementUnit the unit in which the part's amount is measured
- * @ORM\ManyToOne(targetEntity="MeasurementUnit")
- * @ORM\JoinColumn(name="id_part_unit", referencedColumnName="id", nullable=true)
*/
+ #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
+ #[ORM\ManyToOne(targetEntity: MeasurementUnit::class)]
+ #[ORM\JoinColumn(name: 'id_part_unit')]
protected ?MeasurementUnit $partUnit = null;
/**
* Get all part lots where this part is stored.
- *
- * @return PartLot[]|Collection
+ * @phpstan-return Collection
*/
public function getPartLots(): Collection
{
@@ -118,7 +124,7 @@ trait InstockTrait
/**
* Get the count of parts which must be in stock at least.
- * If a integer-based part unit is selected, the value will be rounded to integers.
+ * If an integer-based part unit is selected, the value will be rounded to integers.
*
* @return float count of parts which must be in stock at least
*/
@@ -147,19 +153,45 @@ trait InstockTrait
return false;
}
+ /**
+ * Returns true, if the total instock amount of this part is less than the minimum amount.
+ */
+ public function isNotEnoughInstock(): bool
+ {
+ return $this->getAmountSum() < $this->getMinAmount();
+ }
+
+ /**
+ * Returns true, if at least one of the part lots has an unknown amount.
+ * It is possible that other part lots have a known amount, then getAmountSum() will return sum of all known amounts.
+ * @return bool True if at least one part lot has an unknown amount.
+ */
+ public function isAmountUnknown(): bool
+ {
+ foreach ($this->getPartLots() as $lot) {
+ if ($lot->isInstockUnknown()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/**
* Returns the summed amount of this part (over all part lots)
- * Part Lots that have unknown value or are expired, are not used for this value.
+ * Part Lots that have unknown value or are expired, are not used for this value (counted as 0).
*
* @return float The amount of parts given in partUnit
*/
+ #[Groups(['simple', 'extended', 'full', 'part:read'])]
+ #[SerializedName('total_instock')]
public function getAmountSum(): float
{
//TODO: Find a method to do this natively in SQL, the current method could be a bit slow
$sum = 0;
foreach ($this->getPartLots() as $lot) {
- //Dont use the instock value, if it is unkown
- if ($lot->isInstockUnknown() || $lot->isExpired() ?? false) {
+ //Don't use the in stock value, if it is unknown
+ if ($lot->isInstockUnknown() || ($lot->isExpired() ?? false)) {
continue;
}
@@ -175,7 +207,6 @@ trait InstockTrait
/**
* Returns the summed amount of all part lots that are expired. If no part lots are expired 0 is returned.
- * @return float
*/
public function getExpiredAmountSum(): float
{
diff --git a/src/Entity/Parts/PartTraits/ManufacturerTrait.php b/src/Entity/Parts/PartTraits/ManufacturerTrait.php
index fc27ac14..5d7f8749 100644
--- a/src/Entity/Parts/PartTraits/ManufacturerTrait.php
+++ b/src/Entity/Parts/PartTraits/ManufacturerTrait.php
@@ -22,11 +22,15 @@ 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;
use App\Validator\Constraints\Selectable;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
+use Symfony\Component\Validator\Constraints\Length;
/**
* In this trait all manufacturer related properties of a part are collected (like MPN, manufacturer URL).
@@ -35,31 +39,35 @@ trait ManufacturerTrait
{
/**
* @var Manufacturer|null The manufacturer of this part
- * @ORM\ManyToOne(targetEntity="Manufacturer")
- * @ORM\JoinColumn(name="id_manufacturer", referencedColumnName="id")
- * @Selectable()
*/
+ #[Groups(['simple', 'extended', 'full', 'import', 'part:read', 'part:write'])]
+ #[ORM\ManyToOne(targetEntity: Manufacturer::class)]
+ #[ORM\JoinColumn(name: 'id_manufacturer')]
+ #[Selectable]
protected ?Manufacturer $manufacturer = null;
/**
- * @var string the url to the part on the manufacturer's homepage
- * @ORM\Column(type="string")
- * @Assert\Url()
+ * @var string The url to the part on the manufacturer's homepage
*/
+ #[Assert\Url]
+ #[Groups(['full', 'import', 'part:read', 'part:write'])]
+ #[ORM\Column(type: Types::TEXT)]
protected string $manufacturer_product_url = '';
/**
* @var string The product number used by the manufacturer. If this is set to "", the name field is used.
- * @ORM\Column(type="string")
*/
+ #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
+ #[ORM\Column(type: Types::STRING)]
+ #[Length(max: 255)]
protected string $manufacturer_product_number = '';
/**
- * @var string The production status of this part. Can be one of the specified ones.
- * @ORM\Column(type="string", length=255, nullable=true)
- * @Assert\Choice({"announced", "active", "nrfnd", "eol", "discontinued", ""})
+ * @var ManufacturingStatus|null The production status of this part. Can be one of the specified ones.
*/
- protected ?string $manufacturing_status = '';
+ #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
+ #[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
@@ -107,9 +115,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;
}
@@ -118,9 +126,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/Parts/PartTraits/OrderTrait.php b/src/Entity/Parts/PartTraits/OrderTrait.php
index 60de1cba..2c142016 100644
--- a/src/Entity/Parts/PartTraits/OrderTrait.php
+++ b/src/Entity/Parts/PartTraits/OrderTrait.php
@@ -22,8 +22,10 @@ declare(strict_types=1);
namespace App\Entity\Parts\PartTraits;
+use Doctrine\Common\Collections\Criteria;
+use Doctrine\DBAL\Types\Types;
use App\Entity\PriceInformations\Orderdetail;
-use Doctrine\Common\Collections\ArrayCollection;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use function count;
use Doctrine\Common\Collections\Collection;
@@ -35,36 +37,37 @@ use Doctrine\ORM\Mapping as ORM;
trait OrderTrait
{
/**
- * @var Orderdetail[]|Collection the details about how and where you can order this part
- * @ORM\OneToMany(targetEntity="App\Entity\PriceInformations\Orderdetail", mappedBy="part", cascade={"persist", "remove"}, orphanRemoval=true)
- * @Assert\Valid()
- * @ORM\OrderBy({"supplierpartnr" = "ASC"})
+ * @var Collection The details about how and where you can order this part
*/
- protected $orderdetails;
+ #[Assert\Valid]
+ #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
+ #[ORM\OneToMany(mappedBy: 'part', targetEntity: Orderdetail::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['supplierpartnr' => Criteria::ASC])]
+ protected Collection $orderdetails;
/**
* @var int
- * @ORM\Column(type="integer")
*/
+ #[ORM\Column(type: Types::INTEGER)]
protected int $order_quantity = 0;
/**
* @var bool
- * @ORM\Column(type="boolean")
*/
+ #[ORM\Column(type: Types::BOOLEAN)]
protected bool $manual_order = false;
/**
- * @var Orderdetail
- * @ORM\OneToOne(targetEntity="App\Entity\PriceInformations\Orderdetail")
- * @ORM\JoinColumn(name="order_orderdetails_id", referencedColumnName="id")
+ * @var Orderdetail|null
*/
+ #[ORM\OneToOne(targetEntity: Orderdetail::class)]
+ #[ORM\JoinColumn(name: 'order_orderdetails_id')]
protected ?Orderdetail $order_orderdetail = null;
/**
* Get the selected order orderdetails of this part.
*
- * @return Orderdetail the selected order orderdetails
+ * @return Orderdetail|null the selected order orderdetails
*/
public function getOrderOrderdetails(): ?Orderdetail
{
@@ -96,7 +99,7 @@ trait OrderTrait
*
* @param bool $hide_obsolete If true, obsolete orderdetails will NOT be returned
*
- * @return Collection|Orderdetail[] * all orderdetails as a one-dimensional array of Orderdetails objects
+ * @return Collection * all orderdetails as a one-dimensional array of Orderdetails objects
* (empty array if there are no ones)
* * the array is sorted by the suppliers names / minimum order quantity
*/
@@ -104,9 +107,7 @@ trait OrderTrait
{
//If needed hide the obsolete entries
if ($hide_obsolete) {
- return $this->orderdetails->filter(function (Orderdetail $orderdetail) {
- return ! $orderdetail->getObsolete();
- });
+ return $this->orderdetails->filter(fn(Orderdetail $orderdetail) => ! $orderdetail->getObsolete());
}
return $this->orderdetails;
diff --git a/src/Entity/Parts/PartTraits/ProjectTrait.php b/src/Entity/Parts/PartTraits/ProjectTrait.php
index 58697427..45719377 100644
--- a/src/Entity/Parts/PartTraits/ProjectTrait.php
+++ b/src/Entity/Parts/PartTraits/ProjectTrait.php
@@ -1,30 +1,34 @@
$project_bom_entries
- * @ORM\OneToMany(targetEntity="App\Entity\ProjectSystem\ProjectBOMEntry", mappedBy="part", cascade={"remove"}, orphanRemoval=true)
+ * @var Collection $project_bom_entries
*/
- protected $project_bom_entries = [];
+ #[ORM\OneToMany(mappedBy: 'part', targetEntity: ProjectBOMEntry::class, cascade: ['remove'], orphanRemoval: true)]
+ protected Collection $project_bom_entries;
/**
* @var Project|null If a project is set here, then this part is special and represents the builds of a project.
- * @ORM\OneToOne(targetEntity="App\Entity\ProjectSystem\Project", inversedBy="build_part")
- * @ORM\JoinColumn(nullable=true)
*/
+ #[ORM\OneToOne(inversedBy: 'build_part', targetEntity: Project::class)]
+ #[ORM\JoinColumn]
protected ?Project $built_project = null;
/**
- * Returns all ProjectBOMEntries that use this part.
- * @return Collection|ProjectBOMEntry[]
+ * Returns all ProjectBOMEntries that use this part.
+ *
+ * @phpstan-return Collection
*/
public function getProjectBomEntries(): Collection
{
@@ -35,14 +39,14 @@ trait ProjectTrait
* Checks whether this part represents the builds of a project
* @return bool True if it represents the builds, false if not
*/
+ #[Groups(['part:read'])]
public function isProjectBuildPart(): bool
{
return $this->built_project !== null;
}
/**
- * Returns the project that this part represents the builds of, or null if it doesnt
- * @return Project|null
+ * Returns the project that this part represents the builds of, or null if it doesn't
*/
public function getBuiltProject(): ?Project
{
@@ -78,4 +82,4 @@ trait ProjectTrait
return $projects;
}
-}
\ No newline at end of file
+}
diff --git a/src/Entity/Parts/StorageLocation.php b/src/Entity/Parts/StorageLocation.php
new file mode 100644
index 00000000..6c455ae5
--- /dev/null
+++ b/src/Entity/Parts/StorageLocation.php
@@ -0,0 +1,303 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Entity\Parts;
+
+use Doctrine\Common\Collections\Criteria;
+use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
+use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
+use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Link;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\OpenApi\Model\Operation;
+use ApiPlatform\Serializer\Filter\PropertyFilter;
+use App\ApiPlatform\Filter\LikeFilter;
+use App\Entity\Attachments\Attachment;
+use App\Repository\Parts\StorelocationRepository;
+use Doctrine\DBAL\Types\Types;
+use Doctrine\Common\Collections\ArrayCollection;
+use App\Entity\Attachments\StorageLocationAttachment;
+use App\Entity\Base\AbstractPartsContainingDBElement;
+use App\Entity\Base\AbstractStructuralDBElement;
+use App\Entity\Parameters\StorageLocationParameter;
+use App\Entity\UserSystem\User;
+use Doctrine\Common\Collections\Collection;
+use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Validator\Constraints as Assert;
+
+/**
+ * This entity represents a storage location, where parts can be stored.
+ * @extends AbstractPartsContainingDBElement
+ */
+#[ORM\Entity(repositoryClass: StorelocationRepository::class)]
+#[ORM\Table('`storelocations`')]
+#[ORM\Index(columns: ['name'], name: 'location_idx_name')]
+#[ORM\Index(columns: ['parent_id', 'name'], name: 'location_idx_parent_name')]
+#[ApiResource(
+ operations: [
+ new Get(security: 'is_granted("read", object)'),
+ new GetCollection(security: 'is_granted("@storelocations.read")'),
+ new Post(securityPostDenormalize: 'is_granted("create", object)'),
+ new Patch(security: 'is_granted("edit", object)'),
+ new Delete(security: 'is_granted("delete", object)'),
+ ],
+ normalizationContext: ['groups' => ['location:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
+ denormalizationContext: ['groups' => ['location:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
+)]
+#[ApiResource(
+ uriTemplate: '/storage_locations/{id}/children.{_format}',
+ operations: [
+ new GetCollection(
+ openapi: new Operation(summary: 'Retrieves the children elements of a storage location.'),
+ security: 'is_granted("@storelocations.read")'
+ )
+ ],
+ uriVariables: [
+ 'id' => new Link(fromProperty: 'children', fromClass: Manufacturer::class)
+ ],
+ normalizationContext: ['groups' => ['location:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
+)]
+#[ApiFilter(PropertyFilter::class)]
+#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
+#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
+#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
+class StorageLocation extends AbstractPartsContainingDBElement
+{
+ #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ protected Collection $children;
+
+ #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
+ #[ORM\JoinColumn(name: 'parent_id')]
+ #[Groups(['location:read', 'location:write'])]
+ #[ApiProperty(readableLink: false, writableLink: false)]
+ protected ?AbstractStructuralDBElement $parent = null;
+
+ #[Groups(['location:read', 'location:write'])]
+ protected string $comment = '';
+
+ /**
+ * @var MeasurementUnit|null The measurement unit, which parts can be stored in here
+ */
+ #[ORM\ManyToOne(targetEntity: MeasurementUnit::class)]
+ #[ORM\JoinColumn(name: 'storage_type_id')]
+ #[Groups(['location:read', 'location:write'])]
+ protected ?MeasurementUnit $storage_type = null;
+
+ /** @var Collection
+ */
+ #[Assert\Valid]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: StorageLocationParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
+ #[Groups(['location:read', 'location:write'])]
+ protected Collection $parameters;
+
+ /**
+ * @var bool When this attribute is set, it is not possible to add additional parts or increase the instock of existing parts.
+ */
+ #[Groups(['full', 'import', 'location:read', 'location:write'])]
+ #[ORM\Column(type: Types::BOOLEAN)]
+ protected bool $is_full = false;
+
+ /**
+ * @var bool When this property is set, only one part (but many instock) is allowed to be stored in this store location.
+ */
+ #[Groups(['full', 'import', 'location:read', 'location:write'])]
+ #[ORM\Column(type: Types::BOOLEAN)]
+ protected bool $only_single_part = false;
+
+ /**
+ * @var bool When this property is set, it is only possible to increase the instock of parts, that are already stored here.
+ */
+ #[Groups(['full', 'import', 'location:read', 'location:write'])]
+ #[ORM\Column(type: Types::BOOLEAN)]
+ protected bool $limit_to_existing_parts = false;
+
+ /**
+ * @var User|null The owner of this storage location
+ */
+ #[Assert\Expression('this.getOwner() == null or this.getOwner().isAnonymousUser() === false', message: 'validator.part_lot.owner_must_not_be_anonymous')]
+ #[ORM\ManyToOne(targetEntity: User::class)]
+ #[ORM\JoinColumn(name: 'id_owner', onDelete: 'SET NULL')]
+ #[Groups(['location:read', 'location:write'])]
+ protected ?User $owner = null;
+
+ /**
+ * @var bool If this is set to true, only parts lots, which are owned by the same user as the store location are allowed to be stored here.
+ */
+ #[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
+ #[Groups(['location:read', 'location:write'])]
+ protected bool $part_owner_must_match = false;
+
+ /**
+ * @var Collection
+ */
+ #[Assert\Valid]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: StorageLocationAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[Groups(['location:read', 'location:write'])]
+ protected Collection $attachments;
+
+ #[ORM\ManyToOne(targetEntity: StorageLocationAttachment::class)]
+ #[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
+ #[Groups(['location:read', 'location:write'])]
+ protected ?Attachment $master_picture_attachment = null;
+
+ #[Groups(['location:read'])]
+ protected ?\DateTimeImmutable $addedDate = null;
+ #[Groups(['location:read'])]
+ protected ?\DateTimeImmutable $lastModified = null;
+
+
+ /********************************************************************************
+ *
+ * Getters
+ *
+ *********************************************************************************/
+
+ /**
+ * Get the "is full" attribute.
+ *
+ * When this attribute is set, it is not possible to add additional parts or increase the instock of existing parts.
+ *
+ * @return bool * true if the store location is full
+ * * false if the store location isn't full
+ */
+ public function isFull(): bool
+ {
+ return $this->is_full;
+ }
+
+ /**
+ * When this property is set, only one part (but many instock) is allowed to be stored in this store location.
+ */
+ public function isOnlySinglePart(): bool
+ {
+ return $this->only_single_part;
+ }
+
+ public function setOnlySinglePart(bool $only_single_part): self
+ {
+ $this->only_single_part = $only_single_part;
+
+ return $this;
+ }
+
+ /**
+ * When this property is set, it is only possible to increase the instock of parts, that are already stored here.
+ */
+ public function isLimitToExistingParts(): bool
+ {
+ return $this->limit_to_existing_parts;
+ }
+
+ public function setLimitToExistingParts(bool $limit_to_existing_parts): self
+ {
+ $this->limit_to_existing_parts = $limit_to_existing_parts;
+
+ return $this;
+ }
+
+ public function getStorageType(): ?MeasurementUnit
+ {
+ return $this->storage_type;
+ }
+
+ public function setStorageType(?MeasurementUnit $storage_type): self
+ {
+ $this->storage_type = $storage_type;
+
+ return $this;
+ }
+
+ /**
+ * Returns the owner of this storage location
+ */
+ public function getOwner(): ?User
+ {
+ return $this->owner;
+ }
+
+ /**
+ * Sets the owner of this storage location
+ */
+ public function setOwner(?User $owner): StorageLocation
+ {
+ $this->owner = $owner;
+ return $this;
+ }
+
+ /**
+ * If this is set to true, only parts lots, which are owned by the same user as the store location are allowed to be stored here.
+ */
+ public function isPartOwnerMustMatch(): bool
+ {
+ return $this->part_owner_must_match;
+ }
+
+ /**
+ * If this is set to true, only parts lots, which are owned by the same user as the store location are allowed to be stored here.
+ */
+ public function setPartOwnerMustMatch(bool $part_owner_must_match): StorageLocation
+ {
+ $this->part_owner_must_match = $part_owner_must_match;
+ return $this;
+ }
+
+
+
+
+ /********************************************************************************
+ *
+ * Setters
+ *
+ *********************************************************************************/
+ /**
+ * Change the "is full" attribute of this store location.
+ *
+ * "is_full" = true means that there is no more space in this storelocation.
+ * This attribute is only for information, it has no effect.
+ *
+ * @param bool $new_is_full * true means that the storelocation is full
+ * * false means that the storelocation isn't full
+ */
+ public function setIsFull(bool $new_is_full): self
+ {
+ $this->is_full = $new_is_full;
+
+ return $this;
+ }
+ public function __construct()
+ {
+ parent::__construct();
+ $this->children = new ArrayCollection();
+ $this->parameters = new ArrayCollection();
+ $this->attachments = new ArrayCollection();
+ }
+}
diff --git a/src/Entity/Parts/Storelocation.php b/src/Entity/Parts/Storelocation.php
deleted file mode 100644
index 53e71060..00000000
--- a/src/Entity/Parts/Storelocation.php
+++ /dev/null
@@ -1,187 +0,0 @@
-.
- */
-
-declare(strict_types=1);
-
-namespace App\Entity\Parts;
-
-use App\Entity\Attachments\StorelocationAttachment;
-use App\Entity\Base\AbstractPartsContainingDBElement;
-use App\Entity\Parameters\StorelocationParameter;
-use Doctrine\Common\Collections\Collection;
-use Doctrine\ORM\Mapping as ORM;
-use Symfony\Component\Validator\Constraints as Assert;
-
-/**
- * Class Store location.
- *
- * @ORM\Entity(repositoryClass="App\Repository\Parts\StorelocationRepository")
- * @ORM\Table("`storelocations`", indexes={
- * @ORM\Index(name="location_idx_name", columns={"name"}),
- * @ORM\Index(name="location_idx_parent_name", columns={"parent_id", "name"}),
- * })
- */
-class Storelocation extends AbstractPartsContainingDBElement
-{
- /**
- * @ORM\OneToMany(targetEntity="Storelocation", mappedBy="parent")
- * @ORM\OrderBy({"name" = "ASC"})
- * @var Collection
- */
- protected $children;
-
- /**
- * @ORM\ManyToOne(targetEntity="Storelocation", inversedBy="children")
- * @ORM\JoinColumn(name="parent_id", referencedColumnName="id")
- */
- protected $parent;
-
- /**
- * @var MeasurementUnit|null The measurement unit, which parts can be stored in here
- * @ORM\ManyToOne(targetEntity="MeasurementUnit")
- * @ORM\JoinColumn(name="storage_type_id", referencedColumnName="id")
- */
- protected ?MeasurementUnit $storage_type = null;
-
- /** @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Parameters\StorelocationParameter", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"group" = "ASC" ,"name" = "ASC"})
- * @Assert\Valid()
- */
- protected $parameters;
-
- /**
- * @var bool
- * @ORM\Column(type="boolean")
- */
- protected bool $is_full = false;
-
- /**
- * @var bool
- * @ORM\Column(type="boolean")
- */
- protected bool $only_single_part = false;
-
- /**
- * @var bool
- * @ORM\Column(type="boolean")
- */
- protected bool $limit_to_existing_parts = false;
- /**
- * @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Attachments\StorelocationAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @Assert\Valid()
- */
- protected $attachments;
-
- /********************************************************************************
- *
- * Getters
- *
- *********************************************************************************/
-
- /**
- * Get the "is full" attribute.
- *
- * When this attribute is set, it is not possible to add additional parts or increase the instock of existing parts.
- *
- * @return bool * true if the store location is full
- * * false if the store location isn't full
- */
- public function isFull(): bool
- {
- return $this->is_full;
- }
-
- /**
- * When this property is set, only one part (but many instock) is allowed to be stored in this store location.
- */
- public function isOnlySinglePart(): bool
- {
- return $this->only_single_part;
- }
-
- /**
- * @return Storelocation
- */
- public function setOnlySinglePart(bool $only_single_part): self
- {
- $this->only_single_part = $only_single_part;
-
- return $this;
- }
-
- /**
- * When this property is set, it is only possible to increase the instock of parts, that are already stored here.
- */
- public function isLimitToExistingParts(): bool
- {
- return $this->limit_to_existing_parts;
- }
-
- /**
- * @return Storelocation
- */
- public function setLimitToExistingParts(bool $limit_to_existing_parts): self
- {
- $this->limit_to_existing_parts = $limit_to_existing_parts;
-
- return $this;
- }
-
- public function getStorageType(): ?MeasurementUnit
- {
- return $this->storage_type;
- }
-
- /**
- * @return Storelocation
- */
- public function setStorageType(?MeasurementUnit $storage_type): self
- {
- $this->storage_type = $storage_type;
-
- return $this;
- }
-
- /********************************************************************************
- *
- * Setters
- *
- *********************************************************************************/
-
- /**
- * Change the "is full" attribute of this store location.
- *
- * "is_full" = true means that there is no more space in this storelocation.
- * This attribute is only for information, it has no effect.
- *
- * @param bool $new_is_full * true means that the storelocation is full
- * * false means that the storelocation isn't full
- *
- * @return Storelocation
- */
- public function setIsFull(bool $new_is_full): self
- {
- $this->is_full = $new_is_full;
-
- return $this;
- }
-}
diff --git a/src/Entity/Parts/Supplier.php b/src/Entity/Parts/Supplier.php
index 8183cf91..2c004e9e 100644
--- a/src/Entity/Parts/Supplier.php
+++ b/src/Entity/Parts/Supplier.php
@@ -22,8 +22,29 @@ declare(strict_types=1);
namespace App\Entity\Parts;
+use Doctrine\Common\Collections\Criteria;
+use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
+use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
+use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Link;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\OpenApi\Model\Operation;
+use ApiPlatform\Serializer\Filter\PropertyFilter;
+use App\ApiPlatform\Filter\LikeFilter;
+use App\Entity\Attachments\Attachment;
+use App\Repository\Parts\SupplierRepository;
+use App\Entity\PriceInformations\Orderdetail;
+use Doctrine\Common\Collections\ArrayCollection;
use App\Entity\Attachments\SupplierAttachment;
use App\Entity\Base\AbstractCompany;
+use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parameters\SupplierParameter;
use App\Entity\PriceInformations\Currency;
use App\Validator\Constraints\BigDecimal\BigDecimalPositiveOrZero;
@@ -31,67 +52,103 @@ use App\Validator\Constraints\Selectable;
use Brick\Math\BigDecimal;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
- * Class Supplier.
+ * This entity represents a supplier of parts (the company that sells the parts).
*
- * @ORM\Entity(repositoryClass="App\Repository\Parts\SupplierRepository")
- * @ORM\Table("`suppliers`", indexes={
- * @ORM\Index(name="supplier_idx_name", columns={"name"}),
- * @ORM\Index(name="supplier_idx_parent_name", columns={"parent_id", "name"}),
- * })
+ * @extends AbstractCompany
*/
+#[ORM\Entity(repositoryClass: SupplierRepository::class)]
+#[ORM\Table('`suppliers`')]
+#[ORM\Index(columns: ['name'], name: 'supplier_idx_name')]
+#[ORM\Index(columns: ['parent_id', 'name'], name: 'supplier_idx_parent_name')]
+#[ApiResource(
+ operations: [
+ new Get(security: 'is_granted("read", object)'),
+ new GetCollection(security: 'is_granted("@suppliers.read")'),
+ new Post(securityPostDenormalize: 'is_granted("create", object)'),
+ new Patch(security: 'is_granted("edit", object)'),
+ new Delete(security: 'is_granted("delete", object)'),
+ ],
+ normalizationContext: ['groups' => ['supplier:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
+ denormalizationContext: ['groups' => ['supplier:write', 'company:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
+)]
+#[ApiResource(
+ uriTemplate: '/suppliers/{id}/children.{_format}',
+ operations: [new GetCollection(
+ openapi: new Operation(summary: 'Retrieves the children elements of a supplier.'),
+ security: 'is_granted("@manufacturers.read")'
+ )],
+ uriVariables: [
+ 'id' => new Link(fromProperty: 'children', fromClass: Supplier::class)
+ ],
+ normalizationContext: ['groups' => ['supplier:read', 'company:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
+)]
+#[ApiFilter(PropertyFilter::class)]
+#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
+#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
+#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class Supplier extends AbstractCompany
{
- /**
- * @ORM\OneToMany(targetEntity="Supplier", mappedBy="parent")
- * @ORM\OrderBy({"name" = "ASC"})
- * @var Collection
- */
- protected $children;
+ #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ protected Collection $children;
+
+ #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
+ #[ORM\JoinColumn(name: 'parent_id')]
+ #[Groups(['supplier:read', 'supplier:write'])]
+ #[ApiProperty(readableLink: false, writableLink: false)]
+ protected ?AbstractStructuralDBElement $parent = null;
/**
- * @ORM\ManyToOne(targetEntity="Supplier", inversedBy="children")
- * @ORM\JoinColumn(name="parent_id", referencedColumnName="id")
+ * @var Collection
*/
- protected $parent;
-
- /**
- * @ORM\OneToMany(targetEntity="App\Entity\PriceInformations\Orderdetail", mappedBy="supplier")
- */
- protected $orderdetails;
+ #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: Orderdetail::class)]
+ protected Collection $orderdetails;
/**
* @var Currency|null The currency that should be used by default for order informations with this supplier.
* Set to null, to use global base currency.
- * @ORM\ManyToOne(targetEntity="App\Entity\PriceInformations\Currency")
- * @ORM\JoinColumn(name="default_currency_id", referencedColumnName="id", nullable=true)
- * @Selectable()
*/
+ #[ORM\ManyToOne(targetEntity: Currency::class)]
+ #[ORM\JoinColumn(name: 'default_currency_id')]
+ #[Selectable]
protected ?Currency $default_currency = null;
/**
- * @var BigDecimal|null the shipping costs that have to be paid, when ordering via this supplier
- * @ORM\Column(name="shipping_costs", nullable=true, type="big_decimal", precision=11, scale=5)
- * @BigDecimalPositiveOrZero()
+ * @var BigDecimal|null The shipping costs that have to be paid, when ordering via this supplier
*/
+ #[Groups(['extended', 'full', 'import'])]
+ #[ORM\Column(name: 'shipping_costs', type: 'big_decimal', precision: 11, scale: 5, nullable: true)]
+ #[BigDecimalPositiveOrZero]
protected ?BigDecimal $shipping_costs = null;
/**
* @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Attachments\SupplierAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"name" = "ASC"})
- * @Assert\Valid()
*/
- protected $attachments;
+ #[Assert\Valid]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: SupplierAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ #[Groups(['supplier:read', 'supplier:write'])]
+ #[ApiProperty(readableLink: false, writableLink: true)]
+ protected Collection $attachments;
+
+ #[ORM\ManyToOne(targetEntity: SupplierAttachment::class)]
+ #[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
+ #[Groups(['supplier:read', 'supplier:write'])]
+ #[ApiProperty(readableLink: false, writableLink: true)]
+ protected ?Attachment $master_picture_attachment = null;
/** @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Parameters\SupplierParameter", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"group" = "ASC" ,"name" = "ASC"})
- * @Assert\Valid()
*/
- protected $parameters;
+ #[Assert\Valid]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: SupplierParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
+ #[Groups(['supplier:read', 'supplier:write'])]
+ #[ApiProperty(readableLink: false, writableLink: true)]
+ protected Collection $parameters;
/**
* Gets the currency that should be used by default, when creating a orderdetail with this supplier.
@@ -103,8 +160,6 @@ class Supplier extends AbstractCompany
/**
* Sets the default currency.
- *
- * @return Supplier
*/
public function setDefaultCurrency(?Currency $default_currency): self
{
@@ -127,12 +182,10 @@ class Supplier extends AbstractCompany
* Sets the shipping costs for an order with this supplier.
*
* @param BigDecimal|null $shipping_costs a BigDecimal with the shipping costs
- *
- * @return Supplier
*/
public function setShippingCosts(?BigDecimal $shipping_costs): self
{
- if (null === $shipping_costs) {
+ if (!$shipping_costs instanceof BigDecimal) {
$this->shipping_costs = null;
}
@@ -143,4 +196,12 @@ class Supplier extends AbstractCompany
return $this;
}
+ public function __construct()
+ {
+ parent::__construct();
+ $this->children = new ArrayCollection();
+ $this->orderdetails = new ArrayCollection();
+ $this->attachments = new ArrayCollection();
+ $this->parameters = new ArrayCollection();
+ }
}
diff --git a/src/Entity/PriceInformations/Currency.php b/src/Entity/PriceInformations/Currency.php
index e5d0439d..ce20caf8 100644
--- a/src/Entity/PriceInformations/Currency.php
+++ b/src/Entity/PriceInformations/Currency.php
@@ -22,6 +22,25 @@ declare(strict_types=1);
namespace App\Entity\PriceInformations;
+use Doctrine\Common\Collections\Criteria;
+use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
+use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
+use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Link;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\OpenApi\Model\Operation;
+use ApiPlatform\Serializer\Filter\PropertyFilter;
+use App\ApiPlatform\Filter\LikeFilter;
+use App\Entity\Attachments\Attachment;
+use App\Repository\CurrencyRepository;
+use Doctrine\DBAL\Types\Types;
use App\Entity\Attachments\CurrencyAttachment;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parameters\CurrencyParameter;
@@ -32,71 +51,121 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* This entity describes a currency that can be used for price information.
*
- * @UniqueEntity("iso_code")
- * @ORM\Entity()
- * @ORM\Table(name="currencies", indexes={
- * @ORM\Index(name="currency_idx_name", columns={"name"}),
- * @ORM\Index(name="currency_idx_parent_name", columns={"parent_id", "name"}),
- * })
+ * @extends AbstractStructuralDBElement
*/
+#[UniqueEntity('iso_code')]
+#[ORM\Entity(repositoryClass: CurrencyRepository::class)]
+#[ORM\Table(name: 'currencies')]
+#[ORM\Index(columns: ['name'], name: 'currency_idx_name')]
+#[ORM\Index(columns: ['parent_id', 'name'], name: 'currency_idx_parent_name')]
+#[ApiResource(
+ operations: [
+ new Get(security: 'is_granted("read", object)'),
+ new GetCollection(security: 'is_granted("@currencies.read")'),
+ new Post(securityPostDenormalize: 'is_granted("create", object)'),
+ new Patch(security: 'is_granted("edit", object)'),
+ new Delete(security: 'is_granted("delete", object)'),
+ ],
+ normalizationContext: ['groups' => ['currency:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
+ denormalizationContext: ['groups' => ['currency:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
+)]
+#[ApiResource(
+ uriTemplate: '/currencies/{id}/children.{_format}',
+ operations: [
+ new GetCollection(
+ openapi: new Operation(summary: 'Retrieves the children elements of a currency.'),
+ security: 'is_granted("@currencies.read")'
+ )
+ ],
+ uriVariables: [
+ 'id' => new Link(fromProperty: 'children', fromClass: Currency::class)
+ ],
+ normalizationContext: ['groups' => ['currency:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
+)]
+#[ApiFilter(PropertyFilter::class)]
+#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "iso_code"])]
+#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
+#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class Currency extends AbstractStructuralDBElement
{
- public const PRICE_SCALE = 5;
+ final public const PRICE_SCALE = 5;
/**
* @var BigDecimal|null The exchange rate between this currency and the base currency
* (how many base units the current currency is worth)
- * @ORM\Column(type="big_decimal", precision=11, scale=5, nullable=true)
- * @BigDecimalPositive()
*/
+ #[ORM\Column(type: 'big_decimal', precision: 11, scale: 5, nullable: true)]
+ #[BigDecimalPositive]
+ #[Groups(['currency:read', 'currency:write', 'simple', 'extended', 'full', 'import'])]
+ #[ApiProperty(readableLink: false, writableLink: false)]
protected ?BigDecimal $exchange_rate = null;
+ #[Groups(['currency:read', 'currency:write'])]
+ protected string $comment = "";
+
/**
* @var string the 3-letter ISO code of the currency
- * @ORM\Column(type="string")
- * @Assert\Currency()
*/
+ #[Assert\Currency]
+ #[Assert\NotBlank]
+ #[Groups(['simple', 'extended', 'full', 'import', 'currency:read', 'currency:write'])]
+ #[ORM\Column(type: Types::STRING)]
protected string $iso_code = "";
- /**
- * @ORM\OneToMany(targetEntity="Currency", mappedBy="parent", cascade={"persist"})
- * @ORM\OrderBy({"name" = "ASC"})
- */
- protected $children;
+ #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class, cascade: ['persist'])]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ protected Collection $children;
- /**
- * @ORM\ManyToOne(targetEntity="Currency", inversedBy="children")
- * @ORM\JoinColumn(name="parent_id", referencedColumnName="id")
- */
- protected $parent;
+ #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
+ #[ORM\JoinColumn(name: 'parent_id')]
+ #[Groups(['currency:read', 'currency:write'])]
+ #[ApiProperty(readableLink: false, writableLink: false)]
+ protected ?AbstractStructuralDBElement $parent = null;
/**
* @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Attachments\CurrencyAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"name" = "ASC"})
- * @Assert\Valid()
*/
- protected $attachments;
+ #[Assert\Valid]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: CurrencyAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ #[Groups(['currency:read', 'currency:write'])]
+ protected Collection $attachments;
+
+ #[ORM\ManyToOne(targetEntity: CurrencyAttachment::class)]
+ #[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
+ #[Groups(['currency:read', 'currency:write'])]
+ protected ?Attachment $master_picture_attachment = null;
/** @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Parameters\CurrencyParameter", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"group" = "ASC" ,"name" = "ASC"})
- * @Assert\Valid()
*/
- protected $parameters;
+ #[Assert\Valid]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: CurrencyParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
+ #[Groups(['currency:read', 'currency:write'])]
+ protected Collection $parameters;
/** @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\PriceInformations\Pricedetail", mappedBy="currency")
*/
- protected $pricedetails;
+ #[ORM\OneToMany(mappedBy: 'currency', targetEntity: Pricedetail::class)]
+ protected Collection $pricedetails;
+
+ #[Groups(['currency:read'])]
+ protected ?\DateTimeImmutable $addedDate = null;
+ #[Groups(['currency:read'])]
+ protected ?\DateTimeImmutable $lastModified = null;
+
public function __construct()
{
+ $this->children = new ArrayCollection();
+ $this->attachments = new ArrayCollection();
+ $this->parameters = new ArrayCollection();
$this->pricedetails = new ArrayCollection();
parent::__construct();
}
@@ -111,17 +180,12 @@ class Currency extends AbstractStructuralDBElement
*
* @return string
*/
- public function getIsoCode(): ?string
+ public function getIsoCode(): string
{
return $this->iso_code;
}
- /**
- * @param string|null $iso_code
- *
- * @return Currency
- */
- public function setIsoCode(?string $iso_code): self
+ public function setIsoCode(string $iso_code): self
{
$this->iso_code = $iso_code;
@@ -131,11 +195,12 @@ class Currency extends AbstractStructuralDBElement
/**
* Returns the inverse exchange rate (how many of the current currency the base unit is worth).
*/
+ #[Groups(['currency:read'])]
public function getInverseExchangeRate(): ?BigDecimal
{
$tmp = $this->getExchangeRate();
- if (null === $tmp || $tmp->isZero()) {
+ if (!$tmp instanceof BigDecimal || $tmp->isZero()) {
return null;
}
@@ -156,17 +221,18 @@ class Currency extends AbstractStructuralDBElement
*
* @param BigDecimal|null $exchange_rate The new exchange rate of the currency.
* Set to null, if the exchange rate is unknown.
- *
- * @return Currency
*/
public function setExchangeRate(?BigDecimal $exchange_rate): self
{
- if (null === $exchange_rate) {
+ //If the exchange rate is null, set it to null and return.
+ if ($exchange_rate === null) {
$this->exchange_rate = null;
+ return $this;
}
- $tmp = $exchange_rate->toScale(self::PRICE_SCALE, RoundingMode::HALF_UP);
+
//Only change the object, if the value changes, so that doctrine does not detect it as changed.
- if ((string) $tmp !== (string) $this->exchange_rate) {
+ //Or if the current exchange rate is currently null, as we can not compare it then
+ if ($this->exchange_rate === null || $exchange_rate->compareTo($this->exchange_rate) !== 0) {
$this->exchange_rate = $exchange_rate;
}
diff --git a/src/Entity/PriceInformations/Orderdetail.php b/src/Entity/PriceInformations/Orderdetail.php
index e2f0187f..3709b37d 100644
--- a/src/Entity/PriceInformations/Orderdetail.php
+++ b/src/Entity/PriceInformations/Orderdetail.php
@@ -23,73 +23,128 @@ declare(strict_types=1);
namespace App\Entity\PriceInformations;
+use Doctrine\Common\Collections\Criteria;
+use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
+use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
+use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
+use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Link;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\OpenApi\Model\Operation;
+use ApiPlatform\Serializer\Filter\PropertyFilter;
+use App\ApiPlatform\Filter\LikeFilter;
+use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\TimestampTrait;
use App\Entity\Contracts\NamedElementInterface;
use App\Entity\Contracts\TimeStampableInterface;
use App\Entity\Parts\Part;
use App\Entity\Parts\Supplier;
-use DateTime;
+use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
+use Symfony\Component\Validator\Constraints\Length;
/**
* Class Orderdetail.
- *
- * @ORM\Table("`orderdetails`", indexes={
- * @ORM\Index(name="orderdetails_supplier_part_nr", columns={"supplierpartnr"}),
- * })
- * @ORM\Entity()
- * @ORM\HasLifecycleCallbacks()
- * @UniqueEntity({"supplierpartnr", "supplier", "part"})
*/
+#[UniqueEntity(['supplierpartnr', 'supplier', 'part'])]
+#[ORM\Entity]
+#[ORM\HasLifecycleCallbacks]
+#[ORM\Table('`orderdetails`')]
+#[ORM\Index(columns: ['supplierpartnr'], name: 'orderdetails_supplier_part_nr')]
+#[ApiResource(
+ operations: [
+ new Get(security: 'is_granted("read", object)'),
+ new GetCollection(security: 'is_granted("@parts.read")'),
+ new Post(securityPostDenormalize: 'is_granted("create", object)'),
+ new Patch(security: 'is_granted("edit", object)'),
+ new Delete(security: 'is_granted("delete", object)'),
+ ],
+ normalizationContext: ['groups' => ['orderdetail:read', 'orderdetail:read:standalone', 'api:basic:read', 'pricedetail:read'], 'openapi_definition_name' => 'Read'],
+ denormalizationContext: ['groups' => ['orderdetail:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
+)]
+#[ApiResource(
+ uriTemplate: '/parts/{id}/orderdetails.{_format}',
+ operations: [
+ new GetCollection(
+ openapi: new Operation(summary: 'Retrieves the orderdetails of a part.'),
+ security: 'is_granted("@parts.read")'
+ )
+ ],
+ uriVariables: [
+ 'id' => new Link(toProperty: 'part', fromClass: Part::class)
+ ],
+ normalizationContext: ['groups' => ['orderdetail:read', 'pricedetail:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
+)]
+#[ApiFilter(PropertyFilter::class)]
+#[ApiFilter(PropertyFilter::class)]
+#[ApiFilter(LikeFilter::class, properties: ["supplierpartnr", "supplier_product_url"])]
+#[ApiFilter(BooleanFilter::class, properties: ["obsolete"])]
+#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
+#[ApiFilter(OrderFilter::class, properties: ['supplierpartnr', 'id', 'addedDate', 'lastModified'])]
class Orderdetail extends AbstractDBElement implements TimeStampableInterface, NamedElementInterface
{
use TimestampTrait;
/**
- * @ORM\OneToMany(targetEntity="Pricedetail", mappedBy="orderdetail", cascade={"persist", "remove"}, orphanRemoval=true)
- * @Assert\Valid()
- * @ORM\OrderBy({"min_discount_quantity" = "ASC"})
+ * @var Collection
*/
- protected $pricedetails;
+ #[Assert\Valid]
+ #[Groups(['extended', 'full', 'import', 'orderdetail:read', 'orderdetail:write'])]
+ #[ORM\OneToMany(mappedBy: 'orderdetail', targetEntity: Pricedetail::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['min_discount_quantity' => Criteria::ASC])]
+ protected Collection $pricedetails;
/**
- * @var string
- * @ORM\Column(type="string")
+ * @var string The order number of the part at the supplier
*/
+ #[Groups(['extended', 'full', 'import', 'orderdetail:read', 'orderdetail:write'])]
+ #[ORM\Column(type: Types::STRING)]
+ #[Length(max: 255)]
protected string $supplierpartnr = '';
/**
- * @var bool
- * @ORM\Column(type="boolean")
+ * @var bool True if this part is obsolete/not available anymore at the supplier
*/
+ #[Groups(['extended', 'full', 'import', 'orderdetail:read', 'orderdetail:write'])]
+ #[ORM\Column(type: Types::BOOLEAN)]
protected bool $obsolete = false;
/**
- * @var string
- * @ORM\Column(type="string")
- * @Assert\Url()
+ * @var string The URL to the product on the supplier's website
*/
+ #[Assert\Url]
+ #[Groups(['full', 'import', 'orderdetail:read', 'orderdetail:write'])]
+ #[ORM\Column(type: Types::TEXT)]
protected string $supplier_product_url = '';
/**
- * @var Part
- * @ORM\ManyToOne(targetEntity="App\Entity\Parts\Part", inversedBy="orderdetails")
- * @ORM\JoinColumn(name="part_id", referencedColumnName="id", nullable=false, onDelete="CASCADE")
- * @Assert\NotNull()
+ * @var Part|null The part with which this orderdetail is associated
*/
+ #[Assert\NotNull]
+ #[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'orderdetails')]
+ #[Groups(['orderdetail:read:standalone', 'orderdetail:write'])]
+ #[ORM\JoinColumn(name: 'part_id', nullable: false, onDelete: 'CASCADE')]
protected ?Part $part = null;
/**
- * @var Supplier
- * @ORM\ManyToOne(targetEntity="App\Entity\Parts\Supplier", inversedBy="orderdetails")
- * @ORM\JoinColumn(name="id_supplier", referencedColumnName="id")
- * @Assert\NotNull(message="validator.orderdetail.supplier_must_not_be_null")
+ * @var Supplier|null The supplier of this orderdetail
*/
+ #[Assert\NotNull(message: 'validator.orderdetail.supplier_must_not_be_null')]
+ #[Groups(['extended', 'full', 'import', 'orderdetail:read', 'orderdetail:write'])]
+ #[ORM\ManyToOne(targetEntity: Supplier::class, inversedBy: 'orderdetails')]
+ #[ORM\JoinColumn(name: 'id_supplier')]
protected ?Supplier $supplier = null;
public function __construct()
@@ -113,15 +168,14 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
/**
* Helper for updating the timestamp. It is automatically called by doctrine before persisting.
- *
- * @ORM\PrePersist
- * @ORM\PreUpdate
*/
+ #[ORM\PrePersist]
+ #[ORM\PreUpdate]
public function updateTimestamps(): void
{
- $this->lastModified = new DateTime('now');
- if (null === $this->addedDate) {
- $this->addedDate = new DateTime('now');
+ $this->lastModified = new DateTimeImmutable('now');
+ if (!$this->addedDate instanceof \DateTimeInterface) {
+ $this->addedDate = new DateTimeImmutable('now');
}
if ($this->part instanceof Part) {
@@ -179,6 +233,11 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
return $this->obsolete;
}
+ public function isObsolete(): bool
+ {
+ return $this->getObsolete();
+ }
+
/**
* Get the link to the website of the article on the supplier's website.
*
@@ -193,7 +252,7 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
return $this->supplier_product_url;
}
- if (null === $this->getSupplier()) {
+ if (!$this->getSupplier() instanceof Supplier) {
return '';
}
@@ -203,8 +262,7 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
/**
* Get all pricedetails.
*
- * @return Pricedetail[]|Collection all pricedetails as a one-dimensional array of Pricedetails objects,
- * sorted by minimum discount quantity
+ * @return Collection
*/
public function getPricedetails(): Collection
{
@@ -215,8 +273,6 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
* Adds a price detail to this orderdetail.
*
* @param Pricedetail $pricedetail The pricedetail to add
- *
- * @return Orderdetail
*/
public function addPricedetail(Pricedetail $pricedetail): self
{
@@ -228,8 +284,6 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
/**
* Removes a price detail from this orderdetail.
- *
- * @return Orderdetail
*/
public function removePricedetail(Pricedetail $pricedetail): self
{
@@ -272,11 +326,8 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
* Setters
*
*********************************************************************************/
-
/**
* Sets a new part with which this orderdetail is associated.
- *
- * @return Orderdetail
*/
public function setPart(Part $part): self
{
@@ -287,8 +338,6 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
/**
* Sets the new supplier associated with this orderdetail.
- *
- * @return Orderdetail
*/
public function setSupplier(Supplier $new_supplier): self
{
@@ -301,9 +350,6 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
* Set the supplier part-nr.
*
* @param string $new_supplierpartnr the new supplier-part-nr
- *
- * @return Orderdetail
- * @return Orderdetail
*/
public function setSupplierpartnr(string $new_supplierpartnr): self
{
@@ -316,9 +362,6 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
* Set if the part is obsolete at the supplier of that orderdetails.
*
* @param bool $new_obsolete true means that this part is obsolete
- *
- * @return Orderdetail
- * @return Orderdetail
*/
public function setObsolete(bool $new_obsolete): self
{
@@ -332,13 +375,11 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
* Set this to "", if the function getSupplierProductURL should return the automatic generated URL.
*
* @param string $new_url The new URL for the supplier URL
- *
- * @return Orderdetail
*/
public function setSupplierProductUrl(string $new_url): self
{
//Only change the internal URL if it is not the auto generated one
- if ($new_url === $this->supplier->getAutoProductUrl($this->getSupplierPartNr())) {
+ if ($this->supplier && $new_url === $this->supplier->getAutoProductUrl($this->getSupplierPartNr())) {
return $this;
}
diff --git a/src/Entity/PriceInformations/Pricedetail.php b/src/Entity/PriceInformations/Pricedetail.php
index 0229baf5..86a7bcd5 100644
--- a/src/Entity/PriceInformations/Pricedetail.php
+++ b/src/Entity/PriceInformations/Pricedetail.php
@@ -22,6 +22,15 @@ declare(strict_types=1);
namespace App\Entity\PriceInformations;
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\Serializer\Filter\PropertyFilter;
+use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\TimestampTrait;
use App\Entity\Contracts\TimeStampableInterface;
@@ -29,70 +38,87 @@ use App\Validator\Constraints\BigDecimal\BigDecimalPositive;
use App\Validator\Constraints\Selectable;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
-use DateTime;
+use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Class Pricedetail.
- *
- * @ORM\Entity()
- * @ORM\Table("`pricedetails`", indexes={
- * @ORM\Index(name="pricedetails_idx_min_discount", columns={"min_discount_quantity"}),
- * @ORM\Index(name="pricedetails_idx_min_discount_price_qty", columns={"min_discount_quantity", "price_related_quantity"}),
- * })
- * @ORM\HasLifecycleCallbacks()
- * @UniqueEntity(fields={"min_discount_quantity", "orderdetail"})
*/
+#[UniqueEntity(fields: ['min_discount_quantity', 'orderdetail'])]
+#[ORM\Entity]
+#[ORM\HasLifecycleCallbacks]
+#[ORM\Table('`pricedetails`')]
+#[ORM\Index(columns: ['min_discount_quantity'], name: 'pricedetails_idx_min_discount')]
+#[ORM\Index(columns: ['min_discount_quantity', 'price_related_quantity'], name: 'pricedetails_idx_min_discount_price_qty')]
+#[ApiResource(
+ operations: [
+ new Get(security: 'is_granted("read", object)'),
+ new GetCollection(security: 'is_granted("@parts.read")'),
+ new Post(securityPostDenormalize: 'is_granted("create", object)'),
+ new Patch(security: 'is_granted("edit", object)'),
+ new Delete(security: 'is_granted("delete", object)'),
+ ],
+ normalizationContext: ['groups' => ['pricedetail:read', 'pricedetail:read:standalone', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
+ denormalizationContext: ['groups' => ['pricedetail:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
+)]
+#[ApiFilter(PropertyFilter::class)]
class Pricedetail extends AbstractDBElement implements TimeStampableInterface
{
use TimestampTrait;
- public const PRICE_PRECISION = 5;
+ final public const PRICE_PRECISION = 5;
/**
* @var BigDecimal The price related to the detail. (Given in the selected currency)
- * @ORM\Column(type="big_decimal", precision=11, scale=5)
- * @BigDecimalPositive()
*/
+ #[Groups(['extended', 'full', 'import', 'pricedetail:read', 'pricedetail:write'])]
+ #[ORM\Column(type: 'big_decimal', precision: 11, scale: 5)]
+ #[BigDecimalPositive]
protected BigDecimal $price;
/**
* @var ?Currency The currency used for the current price information.
- * If this is null, the global base unit is assumed.
- * @ORM\ManyToOne(targetEntity="Currency", inversedBy="pricedetails")
- * @ORM\JoinColumn(name="id_currency", referencedColumnName="id", nullable=true)
- * @Selectable()
+ * If this is null, the global base unit is assumed
*/
+ #[Groups(['extended', 'full', 'import', 'pricedetail:read', 'pricedetail:write'])]
+ #[ORM\ManyToOne(targetEntity: Currency::class, inversedBy: 'pricedetails')]
+ #[ORM\JoinColumn(name: 'id_currency')]
+ #[Selectable]
protected ?Currency $currency = null;
/**
- * @var float
- * @ORM\Column(type="float")
- * @Assert\Positive()
+ * @var float The amount/quantity for which the price is for (in part unit)
*/
+ #[Assert\Positive]
+ #[Groups(['extended', 'full', 'import', 'pricedetail:read', 'pricedetail:write'])]
+ #[ORM\Column(type: Types::FLOAT)]
protected float $price_related_quantity = 1.0;
/**
- * @var float
- * @ORM\Column(type="float")
- * @Assert\Positive()
+ * @var float The minimum amount/quantity, which is needed to get this discount (in part unit)
*/
+ #[Assert\Positive]
+ #[Groups(['extended', 'full', 'import', 'pricedetail:read', 'pricedetail:write'])]
+ #[ORM\Column(type: Types::FLOAT)]
protected float $min_discount_quantity = 1.0;
/**
* @var bool
- * @ORM\Column(type="boolean")
*/
+ #[ORM\Column(type: Types::BOOLEAN)]
protected bool $manual_input = true;
/**
* @var Orderdetail|null
- * @ORM\ManyToOne(targetEntity="Orderdetail", inversedBy="pricedetails")
- * @ORM\JoinColumn(name="orderdetails_id", referencedColumnName="id", nullable=false, onDelete="CASCADE")
- * @Assert\NotNull()
*/
+ #[Assert\NotNull]
+ #[ORM\ManyToOne(targetEntity: Orderdetail::class, inversedBy: 'pricedetails')]
+ #[ORM\JoinColumn(name: 'orderdetails_id', nullable: false, onDelete: 'CASCADE')]
+ #[Groups(['pricedetail:read:standalone', 'pricedetail:write'])]
protected ?Orderdetail $orderdetail = null;
public function __construct()
@@ -110,15 +136,14 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
/**
* Helper for updating the timestamp. It is automatically called by doctrine before persisting.
- *
- * @ORM\PrePersist
- * @ORM\PreUpdate
*/
+ #[ORM\PrePersist]
+ #[ORM\PreUpdate]
public function updateTimestamps(): void
{
- $this->lastModified = new DateTime('now');
- if (null === $this->addedDate) {
- $this->addedDate = new DateTime('now');
+ $this->lastModified = new DateTimeImmutable('now');
+ if (!$this->addedDate instanceof \DateTimeInterface) {
+ $this->addedDate = new DateTimeImmutable('now');
}
if ($this->orderdetail instanceof Orderdetail) {
@@ -164,7 +189,9 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
*
* @return BigDecimal the price as a bcmath string
*/
- public function getPricePerUnit($multiplier = 1.0): BigDecimal
+ #[Groups(['pricedetail:read'])]
+ #[SerializedName('price_per_unit')]
+ public function getPricePerUnit(float|string|BigDecimal $multiplier = 1.0): BigDecimal
{
$tmp = BigDecimal::of($multiplier);
$tmp = $tmp->multipliedBy($this->price);
@@ -225,6 +252,18 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
return $this->currency;
}
+ /**
+ * Returns the ISO code of the currency associated with this price information, or null if no currency is selected.
+ * Then the global base currency should be assumed.
+ * @return string|null
+ */
+ #[Groups(['pricedetail:read'])]
+ #[SerializedName('currency_iso_code')]
+ public function getCurrencyISOCode(): ?string
+ {
+ return $this->currency?->getIsoCode();
+ }
+
/********************************************************************************
*
* Setters
@@ -246,8 +285,6 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
/**
* Sets the currency associated with the price information.
* Set to null, to use the global base currency.
- *
- * @return Pricedetail
*/
public function setCurrency(?Currency $currency): self
{
@@ -261,9 +298,9 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
*
* @param BigDecimal $new_price the new price as a float number
*
- * * This is the price for "price_related_quantity" parts!!
- * * Example: if "price_related_quantity" is '10',
- * you have to set here the price for 10 parts!
+ * This is the price for "price_related_quantity" parts!!
+ * Example: if "price_related_quantity" is 10,
+ * you have to set here the price for 10 parts!
*
* @return $this
*/
diff --git a/src/Entity/ProjectSystem/Project.php b/src/Entity/ProjectSystem/Project.php
index 4c71b507..a103d694 100644
--- a/src/Entity/ProjectSystem/Project.php
+++ b/src/Entity/ProjectSystem/Project.php
@@ -22,6 +22,24 @@ declare(strict_types=1);
namespace App\Entity\ProjectSystem;
+use Doctrine\Common\Collections\Criteria;
+use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Link;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\OpenApi\Model\Operation;
+use ApiPlatform\Serializer\Filter\PropertyFilter;
+use App\ApiPlatform\Filter\LikeFilter;
+use App\Entity\Attachments\Attachment;
+use App\Repository\Parts\DeviceRepository;
+use App\Validator\Constraints\UniqueObjectCollection;
+use Doctrine\DBAL\Types\Types;
use App\Entity\Attachments\ProjectAttachment;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parameters\ProjectParameter;
@@ -30,77 +48,120 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
- * Class AttachmentType.
+ * This class represents a project in the database.
*
- * @ORM\Entity(repositoryClass="App\Repository\Parts\DeviceRepository")
- * @ORM\Table(name="projects")
+ * @extends AbstractStructuralDBElement
*/
+#[ORM\Entity(repositoryClass: DeviceRepository::class)]
+#[ORM\Table(name: 'projects')]
+#[ApiResource(
+ operations: [
+ new Get(security: 'is_granted("read", object)'),
+ new GetCollection(security: 'is_granted("@projects.read")'),
+ new Post(securityPostDenormalize: 'is_granted("create", object)'),
+ new Patch(security: 'is_granted("edit", object)'),
+ new Delete(security: 'is_granted("delete", object)'),
+ ],
+ normalizationContext: ['groups' => ['project:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
+ denormalizationContext: ['groups' => ['project:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
+)]
+#[ApiResource(
+ uriTemplate: '/projects/{id}/children.{_format}',
+ operations: [
+ new GetCollection(
+ openapi: new Operation(summary: 'Retrieves the children elements of a project.'),
+ security: 'is_granted("@projects.read")'
+ )
+ ],
+ uriVariables: [
+ 'id' => new Link(fromProperty: 'children', fromClass: Project::class)
+ ],
+ normalizationContext: ['groups' => ['project:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
+)]
+#[ApiFilter(PropertyFilter::class)]
+#[ApiFilter(LikeFilter::class, properties: ["name", "comment"])]
+#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class Project extends AbstractStructuralDBElement
{
- /**
- * @ORM\OneToMany(targetEntity="Project", mappedBy="parent")
- * @ORM\OrderBy({"name" = "ASC"})
- * @var Collection
- */
- protected $children;
+ #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ protected Collection $children;
+
+ #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
+ #[ORM\JoinColumn(name: 'parent_id')]
+ #[Groups(['project:read', 'project:write'])]
+ #[ApiProperty(readableLink: false, writableLink: false)]
+ protected ?AbstractStructuralDBElement $parent = null;
+
+ #[Groups(['project:read', 'project:write'])]
+ protected string $comment = '';
/**
- * @ORM\ManyToOne(targetEntity="Project", inversedBy="children")
- * @ORM\JoinColumn(name="parent_id", referencedColumnName="id")
+ * @var Collection
*/
- protected $parent;
+ #[Assert\Valid]
+ #[Groups(['extended', 'full', 'import'])]
+ #[ORM\OneToMany(mappedBy: 'project', targetEntity: ProjectBOMEntry::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[UniqueObjectCollection(message: 'project.bom_entry.part_already_in_bom', fields: ['part'])]
+ #[UniqueObjectCollection(message: 'project.bom_entry.name_already_in_bom', fields: ['name'])]
+ protected Collection $bom_entries;
- /**
- * @ORM\OneToMany(targetEntity="ProjectBOMEntry", mappedBy="project", cascade={"persist", "remove"}, orphanRemoval=true)
- * @Assert\Valid()
- */
- protected $bom_entries;
-
- /**
- * @ORM\Column(type="integer")
- */
+ #[ORM\Column(type: Types::INTEGER)]
protected int $order_quantity = 0;
/**
- * @var string The current status of the project
- * @ORM\Column(type="string", length=64, nullable=true)
- * @Assert\Choice({"draft","planning","in_production","finished","archived"})
+ * @var string|null The current status of the project
*/
+ #[Assert\Choice(['draft', 'planning', 'in_production', 'finished', 'archived'])]
+ #[Groups(['extended', 'full', 'project:read', 'project:write', 'import'])]
+ #[ORM\Column(type: Types::STRING, length: 64, nullable: true)]
protected ?string $status = null;
/**
* @var Part|null The (optional) part that represents the builds of this project in the stock
- * @ORM\OneToOne(targetEntity="App\Entity\Parts\Part", mappedBy="built_project", cascade={"persist"}, orphanRemoval=true)
*/
+ #[ORM\OneToOne(mappedBy: 'built_project', targetEntity: Part::class, cascade: ['persist'], orphanRemoval: true)]
+ #[Groups(['project:read', 'project:write'])]
protected ?Part $build_part = null;
- /**
- * @ORM\Column(type="boolean")
- */
+ #[ORM\Column(type: Types::BOOLEAN)]
protected bool $order_only_missing_parts = false;
- /**
- * @ORM\Column(type="text", nullable=false, columnDefinition="DEFAULT ''")
- */
+ #[Groups(['simple', 'extended', 'full', 'project:read', 'project:write'])]
+ #[ORM\Column(type: Types::TEXT)]
protected string $description = '';
/**
* @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Attachments\ProjectAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"name" = "ASC"})
*/
- protected $attachments;
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: ProjectAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ #[Groups(['project:read', 'project:write'])]
+ protected Collection $attachments;
+
+ #[ORM\ManyToOne(targetEntity: ProjectAttachment::class)]
+ #[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
+ #[Groups(['project:read', 'project:write'])]
+ protected ?Attachment $master_picture_attachment = null;
/** @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Parameters\ProjectParameter", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"group" = "ASC" ,"name" = "ASC"})
*/
- protected $parameters;
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: ProjectParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
+ #[Groups(['project:read', 'project:write'])]
+ protected Collection $parameters;
+
+ #[Groups(['project:read'])]
+ protected ?\DateTimeImmutable $addedDate = null;
+ #[Groups(['project:read'])]
+ protected ?\DateTimeImmutable $lastModified = null;
+
/********************************************************************************
*
@@ -110,8 +171,11 @@ class Project extends AbstractStructuralDBElement
public function __construct()
{
+ $this->attachments = new ArrayCollection();
+ $this->parameters = new ArrayCollection();
parent::__construct();
$this->bom_entries = new ArrayCollection();
+ $this->children = new ArrayCollection();
}
public function __clone()
@@ -123,7 +187,7 @@ class Project extends AbstractStructuralDBElement
//Set master attachment is needed
foreach ($bom_entries as $bom_entry) {
$clone = clone $bom_entry;
- $this->bom_entries->add($clone);
+ $this->addBomEntry($clone);
}
}
@@ -178,8 +242,6 @@ class Project extends AbstractStructuralDBElement
* Set the "order_only_missing_parts" attribute.
*
* @param bool $new_order_only_missing_parts the new "order_only_missing_parts" attribute
- *
- * @return Project
*/
public function setOrderOnlyMissingParts(bool $new_order_only_missing_parts): self
{
@@ -188,16 +250,12 @@ class Project extends AbstractStructuralDBElement
return $this;
}
- /**
- * @return Collection|ProjectBOMEntry[]
- */
public function getBomEntries(): Collection
{
return $this->bom_entries;
}
/**
- * @param ProjectBOMEntry $entry
* @return $this
*/
public function addBomEntry(ProjectBOMEntry $entry): self
@@ -208,7 +266,6 @@ class Project extends AbstractStructuralDBElement
}
/**
- * @param ProjectBOMEntry $entry
* @return $this
*/
public function removeBomEntry(ProjectBOMEntry $entry): self
@@ -217,18 +274,11 @@ class Project extends AbstractStructuralDBElement
return $this;
}
- /**
- * @return string
- */
public function getDescription(): string
{
return $this->description;
}
- /**
- * @param string $description
- * @return Project
- */
public function setDescription(string $description): Project
{
$this->description = $description;
@@ -253,16 +303,14 @@ class Project extends AbstractStructuralDBElement
/**
* Checks if this project has an associated part representing the builds of this project in the stock.
- * @return bool
*/
public function hasBuildPart(): bool
{
- return $this->build_part !== null;
+ return $this->build_part instanceof Part;
}
/**
* Gets the part representing the builds of this project in the stock, if it is existing
- * @return Part|null
*/
public function getBuildPart(): ?Part
{
@@ -271,25 +319,21 @@ class Project extends AbstractStructuralDBElement
/**
* Sets the part representing the builds of this project in the stock.
- * @param Part|null $build_part
*/
public function setBuildPart(?Part $build_part): void
{
$this->build_part = $build_part;
- if ($build_part) {
+ if ($build_part instanceof Part) {
$build_part->setBuiltProject($this);
}
}
- /**
- * @Assert\Callback
- */
- public function validate(ExecutionContextInterface $context, $payload)
+ #[Assert\Callback]
+ public function validate(ExecutionContextInterface $context, $payload): void
{
//If this project has subprojects, and these have builds part, they must be included in the BOM
foreach ($this->getChildren() as $child) {
- /** @var $child Project */
- if ($child->getBuildPart() === null) {
+ if (!$child->getBuildPart() instanceof Part) {
continue;
}
//We have to search all bom entries for the build part
diff --git a/src/Entity/ProjectSystem/ProjectBOMEntry.php b/src/Entity/ProjectSystem/ProjectBOMEntry.php
index e7ef436f..2a7862ec 100644
--- a/src/Entity/ProjectSystem/ProjectBOMEntry.php
+++ b/src/Entity/ProjectSystem/ProjectBOMEntry.php
@@ -22,6 +22,22 @@ declare(strict_types=1);
namespace App\Entity\ProjectSystem;
+use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
+use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Link;
+use ApiPlatform\Metadata\Patch;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\OpenApi\Model\Operation;
+use ApiPlatform\Serializer\Filter\PropertyFilter;
+use App\ApiPlatform\Filter\LikeFilter;
+use App\Entity\Contracts\TimeStampableInterface;
+use App\Validator\UniqueValidatableInterface;
+use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\TimestampTrait;
use App\Entity\Parts\Part;
@@ -30,119 +46,127 @@ use App\Validator\Constraints\BigDecimal\BigDecimalPositive;
use App\Validator\Constraints\Selectable;
use Brick\Math\BigDecimal;
use Doctrine\ORM\Mapping as ORM;
-use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* The ProjectBOMEntry class represents an entry in a project's BOM.
- *
- * @ORM\Table("project_bom_entries")
- * @ORM\HasLifecycleCallbacks()
- * @ORM\Entity()
- * @UniqueEntity(fields={"part", "project"}, message="project.bom_entry.part_already_in_bom")
- * @UniqueEntity(fields={"name", "project"}, message="project.bom_entry.name_already_in_bom", ignoreNull=true)
*/
-class ProjectBOMEntry extends AbstractDBElement
+#[ORM\HasLifecycleCallbacks]
+#[ORM\Entity]
+#[ORM\Table('project_bom_entries')]
+#[ApiResource(
+ operations: [
+ new Get(uriTemplate: '/project_bom_entries/{id}.{_format}', security: 'is_granted("read", object)',),
+ new GetCollection(uriTemplate: '/project_bom_entries.{_format}', security: 'is_granted("@projects.read")',),
+ new Post(uriTemplate: '/project_bom_entries.{_format}', securityPostDenormalize: 'is_granted("create", object)',),
+ new Patch(uriTemplate: '/project_bom_entries/{id}.{_format}', security: 'is_granted("edit", object)',),
+ new Delete(uriTemplate: '/project_bom_entries/{id}.{_format}', security: 'is_granted("delete", object)',),
+ ],
+ normalizationContext: ['groups' => ['bom_entry:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
+ denormalizationContext: ['groups' => ['bom_entry:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
+)]
+#[ApiResource(
+ uriTemplate: '/projects/{id}/bom.{_format}',
+ operations: [
+ new GetCollection(
+ openapi: new Operation(summary: 'Retrieves the BOM entries of the given project.'),
+ security: 'is_granted("@projects.read")'
+ )
+ ],
+ uriVariables: [
+ 'id' => new Link(fromProperty: 'bom_entries', fromClass: Project::class)
+ ],
+ normalizationContext: ['groups' => ['bom_entry:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
+)]
+#[ApiFilter(PropertyFilter::class)]
+#[ApiFilter(LikeFilter::class, properties: ["name", "comment", 'mountnames'])]
+#[ApiFilter(RangeFilter::class, properties: ['quantity'])]
+#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified', 'quantity'])]
+class ProjectBOMEntry extends AbstractDBElement implements UniqueValidatableInterface, TimeStampableInterface
{
use TimestampTrait;
- /**
- * @var float
- * @ORM\Column(type="float", name="quantity")
- * @Assert\Positive()
- */
- protected float $quantity;
+ #[Assert\Positive]
+ #[ORM\Column(name: 'quantity', type: Types::FLOAT)]
+ #[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full'])]
+ protected float $quantity = 1.0;
/**
* @var string A comma separated list of the names, where this parts should be placed
- * @ORM\Column(type="text", name="mountnames")
*/
- protected string $mountnames;
+ #[ORM\Column(name: 'mountnames', type: Types::TEXT)]
+ #[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full'])]
+ protected string $mountnames = '';
/**
- * @var string An optional name describing this BOM entry (useful for non-part entries)
- * @ORM\Column(type="string", nullable=true)
- * @Assert\Expression(
- * "this.getPart() !== null or this.getName() !== null",
- * message="validator.project.bom_entry.name_or_part_needed"
- * )
+ * @var string|null An optional name describing this BOM entry (useful for non-part entries)
*/
+ #[Assert\Expression('this.getPart() !== null or this.getName() !== null', message: 'validator.project.bom_entry.name_or_part_needed')]
+ #[ORM\Column(type: Types::STRING, nullable: true)]
+ #[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full'])]
protected ?string $name = null;
/**
* @var string An optional comment for this BOM entry
- * @ORM\Column(type="text")
*/
- protected string $comment;
+ #[ORM\Column(type: Types::TEXT)]
+ #[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'extended', 'full'])]
+ protected string $comment = '';
/**
- * @var Project
- * @ORM\ManyToOne(targetEntity="Project", inversedBy="bom_entries")
- * @ORM\JoinColumn(name="id_device", referencedColumnName="id")
+ * @var Project|null
*/
+ #[ORM\ManyToOne(targetEntity: Project::class, inversedBy: 'bom_entries')]
+ #[ORM\JoinColumn(name: 'id_device')]
+ #[Groups(['bom_entry:read', 'bom_entry:write', ])]
protected ?Project $project = null;
/**
* @var Part|null The part associated with this
- * @ORM\ManyToOne(targetEntity="App\Entity\Parts\Part", inversedBy="project_bom_entries")
- * @ORM\JoinColumn(name="id_part", referencedColumnName="id", nullable=true)
*/
+ #[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'project_bom_entries')]
+ #[ORM\JoinColumn(name: 'id_part')]
+ #[Groups(['bom_entry:read', 'bom_entry:write', 'full'])]
protected ?Part $part = null;
/**
- * @var BigDecimal The price of this non-part BOM entry
- * @ORM\Column(type="big_decimal", precision=11, scale=5, nullable=true)
- * @Assert\AtLeastOneOf({
- * @BigDecimalPositive(),
- * @Assert\IsNull()
- * })
+ * @var BigDecimal|null The price of this non-part BOM entry
*/
- protected ?BigDecimal $price;
+ #[Assert\AtLeastOneOf([new BigDecimalPositive(), new Assert\IsNull()])]
+ #[ORM\Column(type: 'big_decimal', precision: 11, scale: 5, nullable: true)]
+ #[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'extended', 'full'])]
+ protected ?BigDecimal $price = null;
/**
* @var ?Currency The currency for the price of this non-part BOM entry
- * @ORM\ManyToOne(targetEntity="App\Entity\PriceInformations\Currency")
- * @ORM\JoinColumn(nullable=true)
- * @Selectable()
*/
+ #[ORM\ManyToOne(targetEntity: Currency::class)]
+ #[ORM\JoinColumn]
+ #[Selectable]
protected ?Currency $price_currency = null;
public function __construct()
{
- $this->price = BigDecimal::zero()->toScale(5);
}
- /**
- * @return float
- */
public function getQuantity(): float
{
return $this->quantity;
}
- /**
- * @param float $quantity
- * @return ProjectBOMEntry
- */
public function setQuantity(float $quantity): ProjectBOMEntry
{
$this->quantity = $quantity;
return $this;
}
- /**
- * @return string
- */
public function getMountnames(): string
{
return $this->mountnames;
}
- /**
- * @param string $mountnames
- * @return ProjectBOMEntry
- */
public function setMountnames(string $mountnames): ProjectBOMEntry
{
$this->mountnames = $mountnames;
@@ -159,7 +183,6 @@ class ProjectBOMEntry extends AbstractDBElement
/**
* @param string $name
- * @return ProjectBOMEntry
*/
public function setName(?string $name): ProjectBOMEntry
{
@@ -167,36 +190,22 @@ class ProjectBOMEntry extends AbstractDBElement
return $this;
}
- /**
- * @return string
- */
public function getComment(): string
{
return $this->comment;
}
- /**
- * @param string $comment
- * @return ProjectBOMEntry
- */
public function setComment(string $comment): ProjectBOMEntry
{
$this->comment = $comment;
return $this;
}
- /**
- * @return Project|null
- */
public function getProject(): ?Project
{
return $this->project;
}
- /**
- * @param Project|null $project
- * @return ProjectBOMEntry
- */
public function setProject(?Project $project): ProjectBOMEntry
{
$this->project = $project;
@@ -205,18 +214,11 @@ class ProjectBOMEntry extends AbstractDBElement
- /**
- * @return Part|null
- */
public function getPart(): ?Part
{
return $this->part;
}
- /**
- * @param Part|null $part
- * @return ProjectBOMEntry
- */
public function setPart(?Part $part): ProjectBOMEntry
{
$this->part = $part;
@@ -226,7 +228,6 @@ class ProjectBOMEntry extends AbstractDBElement
/**
* Returns the price of this BOM entry, if existing.
* Prices are only valid on non-Part BOM entries.
- * @return BigDecimal|null
*/
public function getPrice(): ?BigDecimal
{
@@ -236,24 +237,17 @@ class ProjectBOMEntry extends AbstractDBElement
/**
* Sets the price of this BOM entry.
* Prices are only valid on non-Part BOM entries.
- * @param BigDecimal|null $price
*/
public function setPrice(?BigDecimal $price): void
{
$this->price = $price;
}
- /**
- * @return Currency|null
- */
public function getPriceCurrency(): ?Currency
{
return $this->price_currency;
}
- /**
- * @param Currency|null $price_currency
- */
public function setPriceCurrency(?Currency $price_currency): void
{
$this->price_currency = $price_currency;
@@ -265,22 +259,18 @@ class ProjectBOMEntry extends AbstractDBElement
*/
public function isPartBomEntry(): bool
{
- return $this->part !== null;
+ return $this->part instanceof Part;
}
- /**
- * @Assert\Callback
- */
+ #[Assert\Callback]
public function validate(ExecutionContextInterface $context, $payload): void
{
//Round quantity to whole numbers, if the part is not a decimal part
- if ($this->part) {
- if (!$this->part->getPartUnit() || $this->part->getPartUnit()->isInteger()) {
- $this->quantity = round($this->quantity);
- }
+ if ($this->part instanceof Part && (!$this->part->getPartUnit() || $this->part->getPartUnit()->isInteger())) {
+ $this->quantity = round($this->quantity);
}
//Non-Part BOM entries are rounded
- if ($this->part === null) {
+ if (!$this->part instanceof Part) {
$this->quantity = round($this->quantity);
}
@@ -297,21 +287,21 @@ class ProjectBOMEntry extends AbstractDBElement
}
//Check that the number of mountnames is the same as the (rounded) quantity
- if (!empty($this->mountnames) && count($uniq_mountnames) !== (int) round ($this->quantity)) {
+ if ($this->mountnames !== '' && count($uniq_mountnames) !== (int) round ($this->quantity)) {
$context->buildViolation('project.bom_entry.mountnames_quantity_mismatch')
->atPath('mountnames')
->addViolation();
}
//Prices are only allowed on non-part BOM entries
- if ($this->part !== null && $this->price !== null) {
+ if ($this->part instanceof Part && $this->price instanceof BigDecimal) {
$context->buildViolation('project.bom_entry.price_not_allowed_on_parts')
->atPath('price')
->addViolation();
}
//Check that the part is not the build representation part of this device or one of its parents
- if ($this->part && $this->part->getBuiltProject() !== null) {
+ if ($this->part && $this->part->getBuiltProject() instanceof Project) {
//Get the associated project
$associated_project = $this->part->getBuiltProject();
//Check that it is not the same as the current project neither one of its parents
@@ -328,4 +318,11 @@ class ProjectBOMEntry extends AbstractDBElement
}
+ public function getComparableFields(): array
+ {
+ return [
+ 'name' => $this->getName(),
+ 'part' => $this->getPart()?->getID(),
+ ];
+ }
}
diff --git a/src/Entity/UserSystem/ApiToken.php b/src/Entity/UserSystem/ApiToken.php
new file mode 100644
index 00000000..f5cbf541
--- /dev/null
+++ b/src/Entity/UserSystem/ApiToken.php
@@ -0,0 +1,199 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Entity\UserSystem;
+
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\OpenApi\Model\Operation;
+use ApiPlatform\Serializer\Filter\PropertyFilter;
+use App\Entity\Base\TimestampTrait;
+use App\Entity\Contracts\TimeStampableInterface;
+use App\Repository\UserSystem\ApiTokenRepository;
+use App\State\CurrentApiTokenProvider;
+use App\Validator\Constraints\Year2038BugWorkaround;
+use Doctrine\DBAL\Types\Types;
+use Doctrine\ORM\Mapping as ORM;
+use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Validator\Constraints\Length;
+use Symfony\Component\Validator\Constraints\NotBlank;
+
+#[ORM\Entity(repositoryClass: ApiTokenRepository::class)]
+#[ORM\Table(name: 'api_tokens')]
+#[ORM\HasLifecycleCallbacks]
+#[UniqueEntity(fields: ['name', 'user'])]
+
+#[ApiResource(
+ uriTemplate: '/tokens/current.{_format}',
+ description: 'A token used to authenticate API requests.',
+ operations: [new Get(
+ openapi: new Operation(summary: 'Get information about the API token that is currently used.'),
+ )],
+ normalizationContext: ['groups' => ['token:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
+ provider: CurrentApiTokenProvider::class,
+)]
+#[ApiFilter(PropertyFilter::class)]
+class ApiToken implements TimeStampableInterface
+{
+
+ use TimestampTrait;
+
+ #[ORM\Id]
+ #[ORM\Column(type: Types::INTEGER)]
+ #[ORM\GeneratedValue]
+ protected int $id;
+
+ #[ORM\Column(type: Types::STRING)]
+ #[Length(max: 255)]
+ #[NotBlank]
+ #[Groups('token:read')]
+ protected string $name = '';
+
+ #[ORM\ManyToOne(inversedBy: 'api_tokens')]
+ #[Groups('token:read')]
+ private ?User $user = null;
+
+ #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
+ #[Groups('token:read')]
+ #[Year2038BugWorkaround]
+ private ?\DateTimeImmutable $valid_until;
+
+ #[ORM\Column(length: 68, unique: true)]
+ private string $token;
+
+ #[ORM\Column(type: Types::SMALLINT, enumType: ApiTokenLevel::class)]
+ #[Groups('token:read')]
+ private ApiTokenLevel $level = ApiTokenLevel::READ_ONLY;
+
+ #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
+ #[Groups('token:read')]
+ private ?\DateTimeImmutable $last_time_used = null;
+
+ public function __construct(ApiTokenType $tokenType = ApiTokenType::PERSONAL_ACCESS_TOKEN)
+ {
+ // Generate a rondom token on creation. The tokenType is 3 characters long (plus underscore), so the token is 68 characters long.
+ $this->token = $tokenType->getTokenPrefix() . bin2hex(random_bytes(32));
+
+ //By default, tokens are valid for 1 year.
+ $this->valid_until = new \DateTimeImmutable('+1 year');
+ }
+
+ public function getTokenType(): ApiTokenType
+ {
+ return ApiTokenType::getTypeFromToken($this->token);
+ }
+
+ public function getUser(): ?User
+ {
+ return $this->user;
+ }
+
+ public function setUser(?User $user): ApiToken
+ {
+ $this->user = $user;
+ return $this;
+ }
+
+ public function getValidUntil(): ?\DateTimeImmutable
+ {
+ return $this->valid_until;
+ }
+
+ /**
+ * Checks if the token is still valid.
+ * @return bool
+ */
+ public function isValid(): bool
+ {
+ return $this->valid_until === null || $this->valid_until > new \DateTimeImmutable();
+ }
+
+ public function setValidUntil(?\DateTimeImmutable $valid_until): ApiToken
+ {
+ $this->valid_until = $valid_until;
+ return $this;
+ }
+
+ public function getToken(): string
+ {
+ return $this->token;
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function setName(string $name): ApiToken
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ /**
+ * Gets the last time the token was used to authenticate or null if it was never used.
+ */
+ public function getLastTimeUsed(): ?\DateTimeImmutable
+ {
+ return $this->last_time_used;
+ }
+
+ /**
+ * Sets the last time the token was used to authenticate.
+ * @return ApiToken
+ */
+ public function setLastTimeUsed(?\DateTimeImmutable $last_time_used): ApiToken
+ {
+ $this->last_time_used = $last_time_used;
+ return $this;
+ }
+
+ public function getLevel(): ApiTokenLevel
+ {
+ return $this->level;
+ }
+
+ public function setLevel(ApiTokenLevel $level): ApiToken
+ {
+ $this->level = $level;
+ return $this;
+ }
+
+ /**
+ * Returns the last 4 characters of the token secret, which can be used to identify the token.
+ * @return string
+ */
+ public function getLastTokenChars(): string
+ {
+ return substr($this->token, -4);
+ }
+
+
+}
\ No newline at end of file
diff --git a/src/Entity/UserSystem/ApiTokenLevel.php b/src/Entity/UserSystem/ApiTokenLevel.php
new file mode 100644
index 00000000..3f997300
--- /dev/null
+++ b/src/Entity/UserSystem/ApiTokenLevel.php
@@ -0,0 +1,73 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Entity\UserSystem;
+
+enum ApiTokenLevel: int
+{
+ private const ROLE_READ_ONLY = 'ROLE_API_READ_ONLY';
+ private const ROLE_EDIT = 'ROLE_API_EDIT';
+ private const ROLE_ADMIN = 'ROLE_API_ADMIN';
+ private const ROLE_FULL = 'ROLE_API_FULL';
+
+ /**
+ * The token can only read (non-sensitive) data.
+ */
+ case READ_ONLY = 1;
+ /**
+ * The token can read and edit (non-sensitive) data.
+ */
+ case EDIT = 2;
+ /**
+ * The token can do some administrative tasks (like viewing all log entries), but can not change passwords and create new tokens.
+ */
+ case ADMIN = 3;
+ /**
+ * The token can do everything the user can do.
+ */
+ case FULL = 4;
+
+ /**
+ * Returns the additional roles that the authenticated user should have when using this token.
+ * @return string[]
+ */
+ public function getAdditionalRoles(): array
+ {
+ //The higher roles should always include the lower ones
+ return match ($this) {
+ self::READ_ONLY => [self::ROLE_READ_ONLY],
+ self::EDIT => [self::ROLE_READ_ONLY, self::ROLE_EDIT],
+ self::ADMIN => [self::ROLE_READ_ONLY, self::ROLE_EDIT, self::ROLE_ADMIN],
+ self::FULL => [self::ROLE_READ_ONLY, self::ROLE_EDIT, self::ROLE_ADMIN, self::ROLE_FULL],
+ };
+ }
+
+ /**
+ * Returns the translation key for the name of this token level.
+ * @return string
+ */
+ public function getTranslationKey(): string
+ {
+ return 'api_token.level.' . strtolower($this->name);
+ }
+}
\ No newline at end of file
diff --git a/src/Entity/UserSystem/ApiTokenType.php b/src/Entity/UserSystem/ApiTokenType.php
new file mode 100644
index 00000000..f8beb378
--- /dev/null
+++ b/src/Entity/UserSystem/ApiTokenType.php
@@ -0,0 +1,56 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Entity\UserSystem;
+
+/**
+ * The type of ApiToken.
+ * The enum value is the prefix of the token. It must be 3 characters long.
+ */
+enum ApiTokenType: string
+{
+ case PERSONAL_ACCESS_TOKEN = 'tcp';
+
+ /**
+ * Get the prefix of the token including the underscore
+ * @return string
+ */
+ public function getTokenPrefix(): string
+ {
+ return $this->value . '_';
+ }
+
+ /**
+ * Get the type from the token prefix
+ * @param string $api_token
+ * @return ApiTokenType
+ */
+ public static function getTypeFromToken(string $api_token): ApiTokenType
+ {
+ $parts = explode('_', $api_token);
+ if (count($parts) !== 2) {
+ throw new \InvalidArgumentException('Invalid token format');
+ }
+ return self::from($parts[0]);
+ }
+}
diff --git a/src/Entity/UserSystem/Group.php b/src/Entity/UserSystem/Group.php
index 0b29036f..6da9d35f 100644
--- a/src/Entity/UserSystem/Group.php
+++ b/src/Entity/UserSystem/Group.php
@@ -22,6 +22,10 @@ declare(strict_types=1);
namespace App\Entity\UserSystem;
+use Doctrine\Common\Collections\Criteria;
+use App\Entity\Attachments\Attachment;
+use App\Validator\Constraints\NoLockout;
+use Doctrine\DBAL\Types\Types;
use App\Entity\Attachments\GroupAttachment;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parameters\GroupParameter;
@@ -30,70 +34,75 @@ use App\Validator\Constraints\ValidPermission;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
- * This entity represents an user group.
+ * This entity represents a user group.
*
- * @ORM\Entity()
- * @ORM\Table("`groups`", indexes={
- * @ORM\Index(name="group_idx_name", columns={"name"}),
- * @ORM\Index(name="group_idx_parent_name", columns={"parent_id", "name"}),
- * })
+ * @extends AbstractStructuralDBElement
*/
+#[ORM\Entity]
+#[ORM\Table('`groups`')]
+#[ORM\Index(columns: ['name'], name: 'group_idx_name')]
+#[ORM\Index(columns: ['parent_id', 'name'], name: 'group_idx_parent_name')]
+#[NoLockout]
class Group extends AbstractStructuralDBElement implements HasPermissionsInterface
{
- /**
- * @ORM\OneToMany(targetEntity="Group", mappedBy="parent")
- * @ORM\OrderBy({"name" = "ASC"})
- * @var Collection
- */
- protected $children;
+ #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ protected Collection $children;
+
+ #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
+ #[ORM\JoinColumn(name: 'parent_id')]
+ protected ?AbstractStructuralDBElement $parent = null;
/**
- * @ORM\ManyToOne(targetEntity="Group", inversedBy="children")
- * @ORM\JoinColumn(name="parent_id", referencedColumnName="id")
+ * @var Collection
*/
- protected $parent;
+ #[ORM\OneToMany(mappedBy: 'group', targetEntity: User::class)]
+ protected Collection $users;
/**
- * @ORM\OneToMany(targetEntity="User", mappedBy="group")
- * @var Collection
+ * @var bool If true all users associated with this group must have enabled some kind of two-factor authentication
*/
- protected $users;
+ #[Groups(['extended', 'full', 'import'])]
+ #[ORM\Column(name: 'enforce_2fa', type: Types::BOOLEAN)]
+ protected bool $enforce2FA = false;
- /**
- * @var bool If true all users associated with this group must have enabled some kind of 2 factor authentication
- * @ORM\Column(type="boolean", name="enforce_2fa")
- */
- protected $enforce2FA = false;
/**
* @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Attachments\GroupAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"name" = "ASC"})
- * @Assert\Valid()
*/
- protected $attachments;
+ #[Assert\Valid]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: GroupAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ protected Collection $attachments;
- /**
- * @var PermissionData|null
- * @ValidPermission()
- * @ORM\Embedded(class="PermissionData", columnPrefix="permissions_")
- */
+ #[ORM\ManyToOne(targetEntity: GroupAttachment::class)]
+ #[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
+ protected ?Attachment $master_picture_attachment = null;
+
+ #[Groups(['full'])]
+ #[ORM\Embedded(class: PermissionData::class, columnPrefix: 'permissions_')]
+ #[ValidPermission]
protected ?PermissionData $permissions = null;
- /** @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Parameters\GroupParameter", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"group" = "ASC" ,"name" = "ASC"})
- * @Assert\Valid()
+ /**
+ * @var Collection
*/
- protected $parameters;
+ #[Assert\Valid]
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: GroupParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
+ protected Collection $parameters;
public function __construct()
{
+ $this->attachments = new ArrayCollection();
+ $this->parameters = new ArrayCollection();
parent::__construct();
$this->permissions = new PermissionData();
$this->users = new ArrayCollection();
+ $this->children = new ArrayCollection();
}
/**
@@ -120,7 +129,7 @@ class Group extends AbstractStructuralDBElement implements HasPermissionsInterfa
public function getPermissions(): PermissionData
{
- if ($this->permissions === null) {
+ if (!$this->permissions instanceof PermissionData) {
$this->permissions = new PermissionData();
}
diff --git a/src/Entity/UserSystem/PermissionData.php b/src/Entity/UserSystem/PermissionData.php
index 0ca9cff3..9ebdc9c9 100644
--- a/src/Entity/UserSystem/PermissionData.php
+++ b/src/Entity/UserSystem/PermissionData.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Entity\UserSystem;
+use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* This class is used to store the permissions of a user.
* This has to be an embeddable or otherwise doctrine could not track the changes of the underlying data array (which is serialized to JSON in the database)
- *
- * @ORM\Embeddable()
+ * @see \App\Tests\Entity\UserSystem\PermissionDataTest
*/
+#[ORM\Embeddable]
final class PermissionData implements \JsonSerializable
{
/**
@@ -40,29 +43,24 @@ final class PermissionData implements \JsonSerializable
/**
* The current schema version of the permission data
*/
- public const CURRENT_SCHEMA_VERSION = 2;
-
- /**
- * @var array This array contains the permission values for each permission
- * This array contains the permission values for each permission, in the form of:
- * permission => [
- * operation => value,
- * ]
- * @ORM\Column(type="json", name="data", options={"default": "[]"})
- */
- protected ?array $data = [
- //$ prefixed entries are used for metadata
- '$ver' => self::CURRENT_SCHEMA_VERSION, //The schema version of the permission data
- ];
+ public const CURRENT_SCHEMA_VERSION = 3;
/**
* Creates a new Permission Data Instance using the given data.
- * By default, a empty array is used, meaning
+ * By default, an empty array is used, meaning
*/
- public function __construct(array $data = [])
+ public function __construct(
+ /**
+ * @var array This array contains the permission values for each permission
+ * This array contains the permission values for each permission, in the form of:
+ * permission => [
+ * operation => value,
+ * ]
+ */
+ #[ORM\Column(name: 'data', type: Types::JSON)]
+ protected array $data = []
+ )
{
- $this->data = $data;
-
//If the passed data did not contain a schema version, we set it to the current version
if (!isset($this->data['$ver'])) {
$this->data['$ver'] = self::CURRENT_SCHEMA_VERSION;
@@ -71,8 +69,6 @@ final class PermissionData implements \JsonSerializable
/**
* Checks if any of the operations of the given permission is defined (meaning it is either ALLOW or DENY)
- * @param string $permission
- * @return bool
*/
public function isAnyOperationOfPermissionSet(string $permission): bool
{
@@ -81,7 +77,6 @@ final class PermissionData implements \JsonSerializable
/**
* Returns an associative array containing all defined (non-INHERIT) operations of the given permission.
- * @param string $permission
* @return array An array in the form ["operation" => value], returns an empty array if no operations are defined
*/
public function getAllDefinedOperationsOfPermission(string $permission): array
@@ -96,8 +91,6 @@ final class PermissionData implements \JsonSerializable
/**
* Sets all operations of the given permission via the given array.
* The data is an array in the form [$operation => $value], all existing values will be overwritten/deleted.
- * @param string $permission
- * @param array $data
* @return $this
*/
public function setAllOperationsOfPermission(string $permission, array $data): self
@@ -109,7 +102,6 @@ final class PermissionData implements \JsonSerializable
/**
* Removes a whole permission from the data including all operations (effectivly setting them to INHERIT)
- * @param string $permission
* @return $this
*/
public function removePermission(string $permission): self
@@ -121,14 +113,12 @@ final class PermissionData implements \JsonSerializable
/**
* Check if a permission value is set for the given permission and operation (meaning there value is not inherit).
- * @param string $permission
- * @param string $operation
* @return bool True if the permission value is set, false otherwise
*/
public function isPermissionSet(string $permission, string $operation): bool
{
//We cannot access metadata via normal permission data
- if (strpos($permission, '$') !== false) {
+ if (str_contains($permission, '$')) {
return false;
}
@@ -137,8 +127,6 @@ final class PermissionData implements \JsonSerializable
/**
* Returns the permission value for the given permission and operation.
- * @param string $permission
- * @param string $operation
* @return bool|null True means allow, false means disallow, null means inherit
*/
public function getPermissionValue(string $permission, string $operation): ?bool
@@ -153,15 +141,12 @@ final class PermissionData implements \JsonSerializable
/**
* Sets the permission value for the given permission and operation.
- * @param string $permission
- * @param string $operation
- * @param bool|null $value
* @return $this
*/
public function setPermissionValue(string $permission, string $operation, ?bool $value): self
{
- if ($value === null) {
- //If the value is null, unset the permission value (meaning implicit inherit)
+ //If the value is null, unset the permission value, if it was set befoere (meaning implicit inherit)
+ if ($value === null && isset($this->data[$permission][$operation])) {
unset($this->data[$permission][$operation]);
} else {
//Otherwise, set the pemission value
@@ -186,8 +171,6 @@ final class PermissionData implements \JsonSerializable
/**
* Creates a new Permission Data Instance using the given JSON encoded data
- * @param string $json
- * @return static
* @throws \JsonException
*/
public static function fromJSON(string $json): self
@@ -203,9 +186,8 @@ final class PermissionData implements \JsonSerializable
/**
* Returns an JSON encodable representation of this object.
- * @return array|mixed
*/
- public function jsonSerialize()
+ public function jsonSerialize(): array
{
$ret = [];
@@ -216,9 +198,7 @@ final class PermissionData implements \JsonSerializable
continue;
}
- $ret[$permission] = array_filter($operations, function ($value) {
- return $value !== null;
- });
+ $ret[$permission] = array_filter($operations, static fn($value) => $value !== null);
//If the permission has no operations, unset it
if (empty($ret[$permission])) {
@@ -240,7 +220,6 @@ final class PermissionData implements \JsonSerializable
/**
* Sets the schema version of this permission data
- * @param int $new_version
* @return $this
*/
public function setSchemaVersion(int $new_version): self
@@ -253,4 +232,4 @@ final class PermissionData implements \JsonSerializable
return $this;
}
-}
\ No newline at end of file
+}
diff --git a/src/Entity/UserSystem/U2FKey.php b/src/Entity/UserSystem/U2FKey.php
index 9c1c68a3..d1d864bc 100644
--- a/src/Entity/UserSystem/U2FKey.php
+++ b/src/Entity/UserSystem/U2FKey.php
@@ -22,20 +22,18 @@ declare(strict_types=1);
namespace App\Entity\UserSystem;
+use App\Entity\Contracts\TimeStampableInterface;
+use Doctrine\DBAL\Types\Types;
use App\Entity\Base\TimestampTrait;
use Doctrine\ORM\Mapping as ORM;
use Jbtronics\TFAWebauthn\Model\LegacyU2FKeyInterface;
+use Symfony\Component\Validator\Constraints\Length;
-/**
- * @ORM\Entity
- * @ORM\Table(name="u2f_keys",
- * uniqueConstraints={
- * @ORM\UniqueConstraint(name="user_unique",columns={"user_id",
- * "key_handle"})
- * })
- * @ORM\HasLifecycleCallbacks()
- */
-class U2FKey implements LegacyU2FKeyInterface
+#[ORM\Entity]
+#[ORM\HasLifecycleCallbacks]
+#[ORM\Table(name: 'u2f_keys')]
+#[ORM\UniqueConstraint(name: 'user_unique', columns: ['user_id', 'key_handle'])]
+class U2FKey implements LegacyU2FKeyInterface, TimeStampableInterface
{
use TimestampTrait;
@@ -43,50 +41,43 @@ class U2FKey implements LegacyU2FKeyInterface
* We have to restrict the length here, as InnoDB only supports key index with max. 767 Bytes.
* Max length of keyhandles should be 128. (According to U2F_MAX_KH_SIZE in FIDO example C code).
*
- * @ORM\Column(type="string", length=128)
*
* @var string
**/
- public string $keyHandle;
+ #[ORM\Column(type: Types::STRING, length: 128)]
+ #[Length(max: 128)]
+ public string $keyHandle = '';
/**
- * @ORM\Column(type="string")
- *
* @var string
**/
- public string $publicKey;
+ #[ORM\Column(type: Types::STRING)]
+ public string $publicKey = '';
/**
- * @ORM\Column(type="text")
- *
* @var string
**/
- public string $certificate;
+ #[ORM\Column(type: Types::TEXT)]
+ public string $certificate = '';
/**
- * @ORM\Column(type="string")
- *
- * @var int
+ * @var string
**/
- public int $counter;
+ #[ORM\Column(type: Types::STRING)]
+ public string $counter = '0';
- /**
- * @ORM\Id
- * @ORM\Column(type="integer")
- * @ORM\GeneratedValue(strategy="AUTO")
- */
+ #[ORM\Id]
+ #[ORM\Column(type: Types::INTEGER)]
+ #[ORM\GeneratedValue]
protected int $id;
/**
- * @ORM\Column(type="string")
- *
* @var string
**/
- protected string $name;
+ #[ORM\Column(type: Types::STRING)]
+ protected string $name = '';
- /**
- * @ORM\ManyToOne(targetEntity="App\Entity\UserSystem\User", inversedBy="u2fKeys")
- **/
+ #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'u2fKeys')]
protected ?User $user = null;
public function getKeyHandle(): string
@@ -126,7 +117,7 @@ class U2FKey implements LegacyU2FKeyInterface
return $this;
}
- public function getCounter(): int
+ public function getCounter(): string
{
return $this->counter;
}
@@ -151,9 +142,9 @@ class U2FKey implements LegacyU2FKeyInterface
}
/**
- * Gets the user, this U2F key belongs to.
+ * Gets the user, this U2F key belongs to.
*/
- public function getUser(): User
+ public function getUser(): User|null
{
return $this->user;
}
diff --git a/src/Entity/UserSystem/User.php b/src/Entity/UserSystem/User.php
index 56034dfd..b39bea4f 100644
--- a/src/Entity/UserSystem/User.php
+++ b/src/Entity/UserSystem/User.php
@@ -22,6 +22,23 @@ declare(strict_types=1);
namespace App\Entity\UserSystem;
+use Doctrine\Common\Collections\Criteria;
+use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
+use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
+use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
+use ApiPlatform\Metadata\ApiFilter;
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\OpenApi\Model\Operation;
+use ApiPlatform\Serializer\Filter\PropertyFilter;
+use App\ApiPlatform\Filter\LikeFilter;
+use App\Entity\Attachments\Attachment;
+use App\Repository\UserRepository;
+use App\EntityListeners\TreeCacheInvalidationListener;
+use App\Validator\Constraints\NoLockout;
+use Doctrine\DBAL\Types\Types;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\UserAttachment;
use App\Entity\Base\AbstractNamedDBElement;
@@ -31,7 +48,10 @@ use App\Validator\Constraints\Selectable;
use App\Validator\Constraints\ValidPermission;
use App\Validator\Constraints\ValidTheme;
use Jbtronics\TFAWebauthn\Model\LegacyU2FKeyInterface;
+use Nbgrp\OneloginSamlBundle\Security\User\SamlUserInterface;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Validator\Constraints\Length;
use Webauthn\PublicKeyCredentialUserEntity;
use function count;
use DateTime;
@@ -51,203 +71,275 @@ use Jbtronics\TFAWebauthn\Model\TwoFactorInterface as WebauthnTwoFactorInterface
/**
* This entity represents a user, which can log in and have permissions.
- * Also this entity is able to save some informations about the user, like the names, email-address and other info.
+ * Also, this entity is able to save some information about the user, like the names, email-address and other info.
+ * @see \App\Tests\Entity\UserSystem\UserTest
*
- * @ORM\Entity(repositoryClass="App\Repository\UserRepository")
- * @ORM\Table("`users`", indexes={
- * @ORM\Index(name="user_idx_username", columns={"name"})
- * })
- * @ORM\EntityListeners({"App\EntityListeners\TreeCacheInvalidationListener"})
- * @UniqueEntity("name", message="validator.user.username_already_used")
+ * @extends AttachmentContainingDBElement
*/
-class User extends AttachmentContainingDBElement implements UserInterface, HasPermissionsInterface, TwoFactorInterface, BackupCodeInterface, TrustedDeviceInterface, WebauthnTwoFactorInterface, PreferredProviderInterface, PasswordAuthenticatedUserInterface
+#[UniqueEntity('name', message: 'validator.user.username_already_used')]
+#[ORM\Entity(repositoryClass: UserRepository::class)]
+#[ORM\EntityListeners([TreeCacheInvalidationListener::class])]
+#[ORM\Table('`users`')]
+#[ORM\Index(columns: ['name'], name: 'user_idx_username')]
+#[ORM\AttributeOverrides([
+ new ORM\AttributeOverride(name: 'name', column: new ORM\Column(type: Types::STRING, length: 180, unique: true))
+])]
+#[ApiResource(
+ shortName: 'User',
+ operations: [
+ new Get(
+ openapi: new Operation(summary: 'Get information about the current user.'),
+ security: 'is_granted("read", object)'
+ ),
+ new GetCollection(
+ openapi: new Operation(summary: 'Get all users defined in the system.'),
+ security: 'is_granted("@users.read")'
+ ),
+ ],
+ normalizationContext: ['groups' => ['user:read'], 'openapi_definition_name' => 'Read'],
+)]
+#[ApiFilter(PropertyFilter::class)]
+#[ApiFilter(LikeFilter::class, properties: ["name", "aboutMe"])]
+#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
+#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
+#[NoLockout(groups: ['permissions:edit'])]
+class User extends AttachmentContainingDBElement implements UserInterface, HasPermissionsInterface, TwoFactorInterface,
+ BackupCodeInterface, TrustedDeviceInterface, WebauthnTwoFactorInterface, PreferredProviderInterface, PasswordAuthenticatedUserInterface, SamlUserInterface
{
//use MasterAttachmentTrait;
/**
* The User id of the anonymous user.
*/
- public const ID_ANONYMOUS = 1;
+ final public const ID_ANONYMOUS = 1;
+
+ #[Groups(['user:read'])]
+ protected ?int $id = null;
+
+ #[Groups(['user:read'])]
+ protected ?\DateTimeImmutable $lastModified = null;
+
+ #[Groups(['user:read'])]
+ protected ?\DateTimeImmutable $addedDate = null;
/**
* @var bool Determines if the user is disabled (user can not log in)
- * @ORM\Column(type="boolean")
*/
+ #[Groups(['extended', 'full', 'import', 'user:read'])]
+ #[ORM\Column(type: Types::BOOLEAN)]
protected bool $disabled = false;
/**
* @var string|null The theme
- * @ORM\Column(type="string", name="config_theme", nullable=true)
- * @ValidTheme()
*/
+ #[Groups(['full', 'import', 'user:read'])]
+ #[ORM\Column(name: 'config_theme', type: Types::STRING, nullable: true)]
+ #[ValidTheme]
protected ?string $theme = null;
/**
* @var string|null the hash of a token the user must provide when he wants to reset his password
- * @ORM\Column(type="string", nullable=true)
*/
+ #[ORM\Column(type: Types::STRING, nullable: true)]
protected ?string $pw_reset_token = null;
- /**
- * @ORM\Column(type="text", name="config_instock_comment_a")
- */
+ #[ORM\Column(name: 'config_instock_comment_a', type: Types::TEXT)]
+ #[Groups(['extended', 'full', 'import'])]
protected string $instock_comment_a = '';
- /**
- * @ORM\Column(type="text", name="config_instock_comment_w")
- */
+ #[ORM\Column(name: 'config_instock_comment_w', type: Types::TEXT)]
+ #[Groups(['extended', 'full', 'import'])]
protected string $instock_comment_w = '';
- /** @var int The version of the trusted device cookie. Used to invalidate all trusted device cookies at once.
- * @ORM\Column(type="integer")
+ /**
+ * @var string A self-description of the user as markdown text
*/
+ #[Groups(['full', 'import', 'user:read'])]
+ #[ORM\Column(type: Types::TEXT)]
+ protected string $aboutMe = '';
+
+ /** @var int The version of the trusted device cookie. Used to invalidate all trusted device cookies at once.
+ */
+ #[ORM\Column(type: Types::INTEGER)]
protected int $trustedDeviceCookieVersion = 0;
/**
* @var string[]|null A list of backup codes that can be used, if the user has no access to its Google Authenticator device
- * @ORM\Column(type="json")
*/
+ #[ORM\Column(type: Types::JSON)]
protected ?array $backupCodes = [];
- /**
- * @ORM\Id()
- * @ORM\GeneratedValue()
- * @ORM\Column(type="integer")
- */
- protected ?int $id = null;
-
/**
* @var Group|null the group this user belongs to
- * DO NOT PUT A fetch eager here! Otherwise you can not unset the group of a user! This seems to be some kind of bug in doctrine. Maybe this is fixed in future versions.
- * @ORM\ManyToOne(targetEntity="Group", inversedBy="users")
- * @ORM\JoinColumn(name="group_id", referencedColumnName="id")
- * @Selectable()
+ * DO NOT PUT A fetch eager here! Otherwise, you can not unset the group of a user! This seems to be some kind of bug in doctrine. Maybe this is fixed in future versions.
*/
+ #[Groups(['extended', 'full', 'import', 'user:read'])]
+ #[ORM\ManyToOne(targetEntity: Group::class, inversedBy: 'users')]
+ #[ORM\JoinColumn(name: 'group_id')]
+ #[Selectable]
+ #[ApiProperty(readableLink: true, writableLink: false)]
protected ?Group $group = null;
/**
- * @var string|null The secret used for google authenticator
- * @ORM\Column(name="google_authenticator_secret", type="string", nullable=true)
+ * @var string|null The secret used for Google authenticator
*/
+ #[ORM\Column(name: 'google_authenticator_secret', type: Types::STRING, nullable: true)]
protected ?string $googleAuthenticatorSecret = null;
/**
* @var string|null The timezone the user prefers
- * @ORM\Column(type="string", name="config_timezone", nullable=true)
- * @Assert\Timezone()
*/
+ #[Assert\Timezone]
+ #[Groups(['full', 'import', 'user:read'])]
+ #[ORM\Column(name: 'config_timezone', type: Types::STRING, nullable: true)]
protected ?string $timezone = '';
/**
* @var string|null The language/locale the user prefers
- * @ORM\Column(type="string", name="config_language", nullable=true)
- * @Assert\Language()
*/
+ #[Assert\Language]
+ #[Groups(['full', 'import', 'user:read'])]
+ #[ORM\Column(name: 'config_language', type: Types::STRING, nullable: true)]
protected ?string $language = '';
/**
* @var string|null The email address of the user
- * @ORM\Column(type="string", length=255, nullable=true)
- * @Assert\Email()
*/
+ #[Assert\Email]
+ #[Groups(['simple', 'extended', 'full', 'import', 'user:read'])]
+ #[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
+ #[Length(max: 255)]
protected ?string $email = '';
/**
- * @var string|null The department the user is working
- * @ORM\Column(type="string", length=255, nullable=true)
+ * @var bool True if the user wants to show his email address on his (public) profile
*/
+ #[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
+ #[Groups(['full', 'import', 'user:read'])]
+ protected bool $show_email_on_profile = false;
+
+ /**
+ * @var string|null The department the user is working
+ */
+ #[Groups(['simple', 'extended', 'full', 'import', 'user:read'])]
+ #[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
+ #[Length(max: 255)]
protected ?string $department = '';
/**
* @var string|null The last name of the User
- * @ORM\Column(type="string", length=255, nullable=true)
*/
+ #[Groups(['simple', 'extended', 'full', 'import', 'user:read'])]
+ #[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
+ #[Length(max: 255)]
protected ?string $last_name = '';
/**
* @var string|null The first name of the User
- * @ORM\Column(type="string", length=255, nullable=true)
*/
+ #[Groups(['simple', 'extended', 'full', 'import', 'user:read'])]
+ #[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
+ #[Length(max: 255)]
protected ?string $first_name = '';
/**
* @var bool True if the user needs to change password after log in
- * @ORM\Column(type="boolean")
*/
+ #[Groups(['extended', 'full', 'import', 'user:read'])]
+ #[ORM\Column(type: Types::BOOLEAN)]
protected bool $need_pw_change = true;
/**
* @var string|null The hashed password
- * @ORM\Column(type="string", nullable=true)
*/
+ #[ORM\Column(type: Types::STRING, nullable: true)]
protected ?string $password = null;
- /**
- * @ORM\Column(type="string", length=180, unique=true)
- * @Assert\NotBlank
- * @Assert\Regex("/^[\w\.\+\-\$]+$/", message="user.invalid_username")
- */
+ #[Assert\NotBlank]
+ #[Assert\Regex('/^[\w\.\+\-\$]+[\w\.\+\-\$\@]*$/', message: 'user.invalid_username')]
+ #[Groups(['user:read'])]
protected string $name = '';
/**
- * @var array
- * @ORM\Column(type="json")
+ * @var array|null
*/
+ #[ORM\Column(type: Types::JSON)]
protected ?array $settings = [];
/**
* @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\Attachments\UserAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
- * @ORM\OrderBy({"name" = "ASC"})
*/
- protected $attachments;
+ #[ORM\OneToMany(mappedBy: 'element', targetEntity: UserAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ #[ORM\OrderBy(['name' => Criteria::ASC])]
+ #[Groups(['user:read', 'user:write'])]
+ protected Collection $attachments;
- /** @var DateTime|null The time when the backup codes were generated
- * @ORM\Column(type="datetime", nullable=true)
+ #[ORM\ManyToOne(targetEntity: UserAttachment::class)]
+ #[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
+ #[Groups(['user:read', 'user:write'])]
+ protected ?Attachment $master_picture_attachment = null;
+
+ /** @var \DateTimeImmutable|null The time when the backup codes were generated
*/
- protected ?DateTime $backupCodesGenerationDate = null;
+ #[Groups(['full'])]
+ #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
+ protected ?\DateTimeImmutable $backupCodesGenerationDate = null;
/** @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\UserSystem\U2FKey", mappedBy="user", cascade={"REMOVE"}, orphanRemoval=true)
*/
- protected $u2fKeys;
+ #[ORM\OneToMany(mappedBy: 'user', targetEntity: U2FKey::class, cascade: ['REMOVE'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]
+ protected Collection $u2fKeys;
/**
* @var Collection
- * @ORM\OneToMany(targetEntity="App\Entity\UserSystem\WebauthnKey", mappedBy="user", cascade={"REMOVE"}, orphanRemoval=true)
*/
- protected $webauthn_keys;
+ #[ORM\OneToMany(mappedBy: 'user', targetEntity: WebauthnKey::class, cascade: ['REMOVE'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]
+ protected Collection $webauthn_keys;
+
+ /**
+ * @var Collection
+ */
+ #[ORM\OneToMany(mappedBy: 'user', targetEntity: ApiToken::class, cascade: ['REMOVE'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]
+ private Collection $api_tokens;
/**
* @var Currency|null The currency the user wants to see prices in.
* Dont use fetch=EAGER here, this will cause problems with setting the currency setting.
* TODO: This is most likely a bug in doctrine/symfony related to the UniqueEntity constraint (it makes a db call).
* TODO: Find a way to use fetch EAGER (this improves performance a bit)
- * @ORM\ManyToOne(targetEntity="App\Entity\PriceInformations\Currency")
- * @ORM\JoinColumn(name="currency_id", referencedColumnName="id")
- * @Selectable()
*/
- protected $currency;
+ #[Groups(['extended', 'full', 'import'])]
+ #[ORM\ManyToOne(targetEntity: Currency::class)]
+ #[ORM\JoinColumn(name: 'currency_id')]
+ #[Selectable]
+ protected ?Currency $currency = null;
- /**
- * @var PermissionData
- * @ValidPermission()
- * @ORM\Embedded(class="PermissionData", columnPrefix="permissions_")
- */
+ #[Groups(['simple', 'extended', 'full', 'import'])]
+ #[ORM\Embedded(class: 'PermissionData', columnPrefix: 'permissions_')]
+ #[ValidPermission]
protected ?PermissionData $permissions = null;
/**
- * @var DateTime the time until the password reset token is valid
- * @ORM\Column(type="datetime", nullable=true)
+ * @var \DateTimeImmutable|null the time until the password reset token is valid
*/
- protected $pw_reset_expires;
+ #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
+ protected ?\DateTimeImmutable $pw_reset_expires = null;
+
+ /**
+ * @var bool True if the user was created by a SAML provider (and therefore cannot change its password)
+ */
+ #[Groups(['extended', 'full'])]
+ #[ORM\Column(type: Types::BOOLEAN)]
+ protected bool $saml_user = false;
public function __construct()
{
+ $this->attachments = new ArrayCollection();
parent::__construct();
$this->permissions = new PermissionData();
$this->u2fKeys = new ArrayCollection();
$this->webauthn_keys = new ArrayCollection();
+ $this->api_tokens = new ArrayCollection();
}
/**
@@ -256,7 +348,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
*
* @return string
*/
- public function __toString()
+ public function __toString(): string
{
$tmp = $this->isDisabled() ? ' [DISABLED]' : '';
@@ -298,6 +390,10 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
+ if ($this->saml_user) {
+ $roles[] = 'ROLE_SAML_USER';
+ }
+
return array_unique($roles);
}
@@ -319,8 +415,6 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
/**
* Sets the password hash for this user.
- *
- * @return User
*/
public function setPassword(string $password): self
{
@@ -358,8 +452,6 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
/**
* Sets the currency the users prefers to see prices in.
- *
- * @return User
*/
public function setCurrency(?Currency $currency): self
{
@@ -369,7 +461,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
}
/**
- * Checks if this user is disabled (user cannot login any more).
+ * Checks if this user is disabled (user cannot log in any more).
*
* @return bool true, if the user is disabled
*/
@@ -382,8 +474,6 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
* Sets the status if a user is disabled.
*
* @param bool $disabled true if the user should be disabled
- *
- * @return User
*/
public function setDisabled(bool $disabled): self
{
@@ -394,7 +484,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
public function getPermissions(): PermissionData
{
- if ($this->permissions === null) {
+ if (!$this->permissions instanceof PermissionData) {
$this->permissions = new PermissionData();
}
@@ -411,8 +501,6 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
/**
* Set the status, if the user needs a password change.
- *
- * @return User
*/
public function setNeedPwChange(bool $need_pw_change): self
{
@@ -431,8 +519,6 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
/**
* Sets the encrypted password reset token.
- *
- * @return User
*/
public function setPwResetToken(?string $pw_reset_token): self
{
@@ -442,19 +528,17 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
}
/**
- * Gets the datetime when the password reset token expires.
+ * Gets the datetime when the password reset token expires.
*/
- public function getPwResetExpires(): DateTime
+ public function getPwResetExpires(): \DateTimeImmutable|null
{
return $this->pw_reset_expires;
}
/**
* Sets the datetime when the password reset token expires.
- *
- * @return User
*/
- public function setPwResetExpires(DateTime $pw_reset_expires): self
+ public function setPwResetExpires(\DateTimeImmutable $pw_reset_expires): self
{
$this->pw_reset_expires = $pw_reset_expires;
@@ -473,11 +557,12 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
*
* @return string a string with the full name of this user
*/
+ #[Groups(['user:read'])]
public function getFullName(bool $including_username = false): string
{
$tmp = $this->getFirstName();
- //Dont add a space, if the name has only one part (it would look strange)
- if (!empty($this->getFirstName()) && !empty($this->getLastName())) {
+ //Don't add a space, if the name has only one part (it would look strange)
+ if ($this->getFirstName() !== null && $this->getFirstName() !== '' && ($this->getLastName() !== null && $this->getLastName() !== '')) {
$tmp .= ' ';
}
$tmp .= $this->getLastName();
@@ -564,8 +649,6 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
* Change the department of the user.
*
* @param string|null $department The new department
- *
- * @return User
*/
public function setDepartment(?string $department): self
{
@@ -599,9 +682,47 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
}
/**
- * Gets the language the user prefers (as 2 letter ISO code).
+ * Gets whether the email address of the user is shown on the public profile page.
+ */
+ public function isShowEmailOnProfile(): bool
+ {
+ return $this->show_email_on_profile;
+ }
+
+ /**
+ * Sets whether the email address of the user is shown on the public profile page.
+ */
+ public function setShowEmailOnProfile(bool $show_email_on_profile): User
+ {
+ $this->show_email_on_profile = $show_email_on_profile;
+ return $this;
+ }
+
+
+
+ /**
+ * Returns the about me text of the user.
+ */
+ public function getAboutMe(): string
+ {
+ return $this->aboutMe;
+ }
+
+ /**
+ * Change the about me text of the user.
+ */
+ public function setAboutMe(string $aboutMe): User
+ {
+ $this->aboutMe = $aboutMe;
+ return $this;
+ }
+
+
+
+ /**
+ * Gets the language the user prefers (as 2-letter ISO code).
*
- * @return string|null The 2 letter ISO code of the preferred language (e.g. 'en' or 'de').
+ * @return string|null The 2-letter ISO code of the preferred language (e.g. 'en' or 'de').
* If null is returned, the user has not specified a language and the server wide language should be used.
*/
public function getLanguage(): ?string
@@ -612,10 +733,8 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
/**
* Change the language the user prefers.
*
- * @param string|null $language The new language as 2 letter ISO code (e.g. 'en' or 'de').
- * Set to null, to use the system wide language.
- *
- * @return User
+ * @param string|null $language The new language as 2-letter ISO code (e.g. 'en' or 'de').
+ * Set to null, to use the system-wide language.
*/
public function setLanguage(?string $language): self
{
@@ -660,8 +779,8 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
/**
* Change the theme the user wants to see.
*
- * @param string|null $theme The name of the theme (See See self::AVAILABLE_THEMES for valid values). Set to null
- * if the system wide theme should be used.
+ * @param string|null $theme The name of the theme (See self::AVAILABLE_THEMES for valid values). Set to null
+ * if the system-wide theme should be used.
*
* @return $this
*/
@@ -705,7 +824,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
}
/**
- * Return the user name that should be shown in Google Authenticator.
+ * Return the username that should be shown in Google Authenticator.
*/
public function getGoogleAuthenticatorUsername(): string
{
@@ -774,17 +893,11 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
* @param string[] $codes An array containing the backup codes
*
* @return $this
- *
- * @throws Exception If an error with the datetime occurs
*/
public function setBackupCodes(array $codes): self
{
$this->backupCodes = $codes;
- if (empty($codes)) {
- $this->backupCodesGenerationDate = null;
- } else {
- $this->backupCodesGenerationDate = new DateTime();
- }
+ $this->backupCodesGenerationDate = $codes === [] ? null : new \DateTimeImmutable();
return $this;
}
@@ -792,7 +905,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
/**
* Return the date when the backup codes were generated.
*/
- public function getBackupCodesGenerationDate(): ?DateTime
+ public function getBackupCodesGenerationDate(): ?\DateTimeImmutable
{
return $this->backupCodesGenerationDate;
}
@@ -809,7 +922,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
/**
* Invalidate all trusted device tokens at once, by incrementing the token version.
- * You have to flush the changes to database afterwards.
+ * You have to flush the changes to database afterward.
*/
public function invalidateTrustedDeviceTokens(): void
{
@@ -846,7 +959,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
{
return new PublicKeyCredentialUserEntity(
$this->getUsername(),
- (string) $this->getId(),
+ (string) $this->getID(),
$this->getFullName(),
);
}
@@ -860,4 +973,81 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
{
$this->webauthn_keys->add($webauthnKey);
}
+
+ /**
+ * Returns true, if the user was created by the SAML authentication.
+ */
+ public function isSamlUser(): bool
+ {
+ return $this->saml_user;
+ }
+
+ /**
+ * Sets the saml_user flag.
+ */
+ public function setSamlUser(bool $saml_user): User
+ {
+ $this->saml_user = $saml_user;
+ return $this;
+ }
+
+ public function setSamlAttributes(array $attributes): void
+ {
+ //When mail attribute exists, set it
+ if (isset($attributes['email'])) {
+ $this->setEmail($attributes['email'][0]);
+ }
+ //When first name attribute exists, set it
+ if (isset($attributes['firstName'])) {
+ $this->setFirstName($attributes['firstName'][0]);
+ }
+ //When last name attribute exists, set it
+ if (isset($attributes['lastName'])) {
+ $this->setLastName($attributes['lastName'][0]);
+ }
+ if (isset($attributes['department'])) {
+ $this->setDepartment($attributes['department'][0]);
+ }
+
+ //Use X500 attributes as userinfo
+ if (isset($attributes['urn:oid:2.5.4.42'])) {
+ $this->setFirstName($attributes['urn:oid:2.5.4.42'][0]);
+ }
+ if (isset($attributes['urn:oid:2.5.4.4'])) {
+ $this->setLastName($attributes['urn:oid:2.5.4.4'][0]);
+ }
+ if (isset($attributes['urn:oid:1.2.840.113549.1.9.1'])) {
+ $this->setEmail($attributes['urn:oid:1.2.840.113549.1.9.1'][0]);
+ }
+ }
+
+ /**
+ * Return all API tokens of the user.
+ * @return Collection
+ */
+ public function getApiTokens(): Collection
+ {
+ return $this->api_tokens;
+ }
+
+ /**
+ * Add an API token to the user.
+ * @param ApiToken $apiToken
+ * @return void
+ */
+ public function addApiToken(ApiToken $apiToken): void
+ {
+ $apiToken->setUser($this);
+ $this->api_tokens->add($apiToken);
+ }
+
+ /**
+ * Remove an API token from the user.
+ * @param ApiToken $apiToken
+ * @return void
+ */
+ public function removeApiToken(ApiToken $apiToken): void
+ {
+ $this->api_tokens->removeElement($apiToken);
+ }
}
diff --git a/src/Entity/UserSystem/WebauthnKey.php b/src/Entity/UserSystem/WebauthnKey.php
index 5d5c654d..b2716e07 100644
--- a/src/Entity/UserSystem/WebauthnKey.php
+++ b/src/Entity/UserSystem/WebauthnKey.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Entity\UserSystem;
+use App\Entity\Contracts\TimeStampableInterface;
+use Doctrine\DBAL\Types\Types;
use App\Entity\Base\TimestampTrait;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Validator\Constraints\Length;
+use Symfony\Component\Validator\Constraints\NotBlank;
use Webauthn\PublicKeyCredentialSource as BasePublicKeyCredentialSource;
-/**
- * @ORM\Table(name="webauthn_keys")
- * @ORM\Entity()
- * @ORM\HasLifecycleCallbacks()
- */
-class WebauthnKey extends BasePublicKeyCredentialSource
+#[ORM\Entity]
+#[ORM\HasLifecycleCallbacks]
+#[ORM\Table(name: 'webauthn_keys')]
+class WebauthnKey extends BasePublicKeyCredentialSource implements TimeStampableInterface
{
use TimestampTrait;
- /**
- * @ORM\Id
- * @ORM\Column(type="integer")
- * @ORM\GeneratedValue(strategy="AUTO")
- */
+ #[ORM\Id]
+ #[ORM\Column(type: Types::INTEGER)]
+ #[ORM\GeneratedValue]
protected int $id;
- /**
- * @ORM\Column(type="string")
- */
- protected string $name;
+ #[ORM\Column(type: Types::STRING)]
+ #[NotBlank]
+ #[Length(max: 255)]
+ protected string $name = '';
- /**
- * @ORM\ManyToOne(targetEntity="App\Entity\UserSystem\User", inversedBy="webauthn_keys")
- **/
+ #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'webauthn_keys')]
protected ?User $user = null;
- /**
- * @return string
- */
+ #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
+ protected ?\DateTimeImmutable $last_time_used = null;
+
public function getName(): string
{
return $this->name;
}
- /**
- * @param string $name
- * @return WebauthnKey
- */
public function setName(string $name): WebauthnKey
{
$this->name = $name;
return $this;
}
- /**
- * @return User|null
- */
public function getUser(): ?User
{
return $this->user;
}
- /**
- * @param User|null $user
- * @return WebauthnKey
- */
public function setUser(?User $user): WebauthnKey
{
$this->user = $user;
return $this;
}
- /**
- * @return int
- */
public function getId(): int
{
return $this->id;
}
+ /**
+ * Retrieve the last time when the key was used.
+ */
+ public function getLastTimeUsed(): ?\DateTimeImmutable
+ {
+ return $this->last_time_used;
+ }
-
-
+ /**
+ * Update the last time when the key was used.
+ * @return void
+ */
+ public function updateLastTimeUsed(): void
+ {
+ $this->last_time_used = new \DateTimeImmutable('now');
+ }
public static function fromRegistration(BasePublicKeyCredentialSource $registration): self
{
@@ -113,4 +112,4 @@ class WebauthnKey extends BasePublicKeyCredentialSource
$registration->getOtherUI()
);
}
-}
\ No newline at end of file
+}
diff --git a/src/EntityListeners/AttachmentDeleteListener.php b/src/EntityListeners/AttachmentDeleteListener.php
index edf68e25..1f39b2d0 100644
--- a/src/EntityListeners/AttachmentDeleteListener.php
+++ b/src/EntityListeners/AttachmentDeleteListener.php
@@ -22,12 +22,12 @@ declare(strict_types=1);
namespace App\EntityListeners;
+use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\Attachment;
use App\Services\Attachments\AttachmentManager;
use App\Services\Attachments\AttachmentPathResolver;
use App\Services\Attachments\AttachmentReverseSearch;
use Doctrine\ORM\EntityManagerInterface;
-use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PostRemoveEventArgs;
use Doctrine\ORM\Event\PreRemoveEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
@@ -38,30 +38,22 @@ use SplFileInfo;
/**
* This listener watches for changes on attachments and deletes the files associated with an attachment, that are not
- * used any more. This can happens after an attachment is delteted or the path is changed.
+ * used anymore. This can happen after an attachment is deleted or the path is changed.
*/
class AttachmentDeleteListener
{
- protected AttachmentReverseSearch $attachmentReverseSearch;
- protected AttachmentManager $attachmentHelper;
- protected AttachmentPathResolver $pathResolver;
-
- public function __construct(AttachmentReverseSearch $attachmentReverseSearch, AttachmentManager $attachmentHelper, AttachmentPathResolver $pathResolver)
+ public function __construct(protected AttachmentReverseSearch $attachmentReverseSearch, protected AttachmentManager $attachmentHelper, protected AttachmentPathResolver $pathResolver)
{
- $this->attachmentReverseSearch = $attachmentReverseSearch;
- $this->attachmentHelper = $attachmentHelper;
- $this->pathResolver = $pathResolver;
}
/**
* Removes the file associated with the attachment, if the file associated with the attachment changes.
- *
- * @PreUpdate
*/
+ #[PreUpdate]
public function preUpdateHandler(Attachment $attachment, PreUpdateEventArgs $event): void
{
- if ($event->hasChangedField('path')) {
- $old_path = $event->getOldValue('path');
+ if ($event->hasChangedField('internal_path')) {
+ $old_path = $event->getOldValue('internal_path');
//Dont delete file if the attachment uses a builtin ressource:
if (Attachment::checkIfBuiltin($old_path)) {
@@ -82,15 +74,14 @@ class AttachmentDeleteListener
/**
* Ensure that attachments are not used in preview, so that they can be deleted (without integrity violation).
- *
- * @ORM\PreRemove()
*/
+ #[ORM\PreRemove]
public function preRemoveHandler(Attachment $attachment, PreRemoveEventArgs $event): void
{
//Ensure that the attachment that will be deleted, is not used as preview picture anymore...
$attachment_holder = $attachment->getElement();
- if (null === $attachment_holder) {
+ if (!$attachment_holder instanceof AttachmentContainingDBElement) {
return;
}
@@ -103,16 +94,15 @@ class AttachmentDeleteListener
if (!$em instanceof EntityManagerInterface) {
throw new \RuntimeException('Invalid EntityManagerInterface!');
}
- $classMetadata = $em->getClassMetadata(get_class($attachment_holder));
+ $classMetadata = $em->getClassMetadata($attachment_holder::class);
$em->getUnitOfWork()->computeChangeSet($classMetadata, $attachment_holder);
}
}
/**
* Removes the file associated with the attachment, after the attachment was deleted.
- *
- * @PostRemove
*/
+ #[PostRemove]
public function postRemoveHandler(Attachment $attachment, PostRemoveEventArgs $event): void
{
//Dont delete file if the attachment uses a builtin ressource:
@@ -122,7 +112,7 @@ class AttachmentDeleteListener
$file = $this->attachmentHelper->attachmentToFile($attachment);
//Only delete if the attachment has a valid file.
- if (null !== $file) {
+ if ($file instanceof \SplFileInfo) {
/* The original file has already been removed, so we have to decrease the threshold to zero,
as any remaining attachment depends on this attachment, and we must not delete this file! */
$this->attachmentReverseSearch->deleteIfNotUsed($file, 0);
diff --git a/src/EntityListeners/TreeCacheInvalidationListener.php b/src/EntityListeners/TreeCacheInvalidationListener.php
index 017c8018..eae7ce35 100644
--- a/src/EntityListeners/TreeCacheInvalidationListener.php
+++ b/src/EntityListeners/TreeCacheInvalidationListener.php
@@ -24,56 +24,52 @@ namespace App\EntityListeners;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
-use App\Entity\LabelSystem\LabelProfile;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
-use App\Services\UserSystem\UserCacheKeyGenerator;
-use Doctrine\ORM\Event\LifecycleEventArgs;
+use App\Services\Cache\ElementCacheTagGenerator;
+use App\Services\Cache\UserCacheKeyGenerator;
+use Doctrine\ORM\Event\PostPersistEventArgs;
+use Doctrine\ORM\Event\PostRemoveEventArgs;
+use Doctrine\ORM\Event\PostUpdateEventArgs;
use Doctrine\ORM\Mapping as ORM;
-use function get_class;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
class TreeCacheInvalidationListener
{
- protected TagAwareCacheInterface $cache;
- protected UserCacheKeyGenerator $keyGenerator;
-
- public function __construct(TagAwareCacheInterface $treeCache, UserCacheKeyGenerator $keyGenerator)
+ public function __construct(
+ protected TagAwareCacheInterface $cache,
+ protected UserCacheKeyGenerator $keyGenerator,
+ protected ElementCacheTagGenerator $tagGenerator
+ )
{
- $this->cache = $treeCache;
- $this->keyGenerator = $keyGenerator;
}
- /**
- * @ORM\PostUpdate()
- * @ORM\PostPersist()
- * @ORM\PostRemove()
- */
- public function invalidate(AbstractDBElement $element, LifecycleEventArgs $event): void
+ #[ORM\PostUpdate]
+ #[ORM\PostPersist]
+ #[ORM\PostRemove]
+ public function invalidate(AbstractDBElement $element, PostUpdateEventArgs|PostPersistEventArgs|PostRemoveEventArgs $event): void
{
- //If an element was changed, then invalidate all cached trees with this element class
- if ($element instanceof AbstractStructuralDBElement || $element instanceof LabelProfile) {
- $secure_class_name = str_replace('\\', '_', get_class($element));
- $this->cache->invalidateTags([$secure_class_name]);
+ //For all changes, we invalidate the cache for all elements of this class
+ $tags = [$this->tagGenerator->getElementTypeCacheTag($element)];
- //Trigger a sidebar reload for all users (see SidebarTreeUpdater service)
- if(!$element instanceof LabelProfile) {
- $this->cache->invalidateTags(['sidebar_tree_update']);
- }
+
+ //For changes on structural elements, we also invalidate the sidebar tree
+ if ($element instanceof AbstractStructuralDBElement) {
+ $tags[] = 'sidebar_tree_update';
}
- //If a user change, then invalidate all cached trees for him
+ //For user changes, we invalidate the cache for this user
if ($element instanceof User) {
- $secure_class_name = str_replace('\\', '_', get_class($element));
- $tag = $this->keyGenerator->generateKey($element);
- $this->cache->invalidateTags([$tag, $secure_class_name]);
+ $tags[] = $this->keyGenerator->generateKey($element);
}
/* If any group change, then invalidate all cached trees. Users Permissions can be inherited from groups,
so a change in any group can cause big permisssion changes for users. So to be sure, invalidate all trees */
if ($element instanceof Group) {
- $tag = 'groups';
- $this->cache->invalidateTags([$tag]);
+ $tags[] = 'groups';
}
+
+ //Invalidate the cache for the given tags
+ $this->cache->invalidateTags($tags);
}
}
diff --git a/src/EventListener/AddEditCommentRequestListener.php b/src/EventListener/AddEditCommentRequestListener.php
new file mode 100644
index 00000000..33c72b3f
--- /dev/null
+++ b/src/EventListener/AddEditCommentRequestListener.php
@@ -0,0 +1,62 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\EventListener;
+
+use App\Services\LogSystem\EventCommentHelper;
+use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
+use Symfony\Component\HttpKernel\Event\RequestEvent;
+
+#[AsEventListener]
+class AddEditCommentRequestListener
+{
+ public function __construct(private readonly EventCommentHelper $helper)
+ {
+
+ }
+
+ public function __invoke(RequestEvent $event)
+ {
+ if (!$event->isMainRequest()) {
+ return;
+ }
+ $request = $event->getRequest();
+
+ //Do not add comment if the request is a GET request
+ if ($request->isMethod('GET')) {
+ return;
+ }
+
+ //Check if the user tries to access a /api/ endpoint, if not skip
+ if (!str_contains($request->getPathInfo(), '/api/')) {
+ return;
+ }
+
+ //Extract the comment from the query parameter
+ $comment = $request->query->getString('_comment', '');
+
+ if ($comment !== '') {
+ $this->helper->setMessage($comment);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EventListener/AllowSlowNaturalSortListener.php b/src/EventListener/AllowSlowNaturalSortListener.php
new file mode 100644
index 00000000..02ec6144
--- /dev/null
+++ b/src/EventListener/AllowSlowNaturalSortListener.php
@@ -0,0 +1,49 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\EventListener;
+
+use App\Doctrine\Functions\Natsort;
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
+use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
+use Symfony\Component\HttpKernel\Event\RequestEvent;
+
+/**
+ * This is a workaround to the fact that we can not inject parameters into doctrine custom functions.
+ * Therefore we use this event listener to call the static function on the custom function, to inject the value, before
+ * any NATSORT function is called.
+ */
+#[AsEventListener]
+class AllowSlowNaturalSortListener
+{
+ public function __construct(
+ #[Autowire(param: 'partdb.db.emulate_natural_sort')]
+ private readonly bool $allowNaturalSort)
+ {
+ }
+
+ public function __invoke(RequestEvent $event)
+ {
+ Natsort::allowSlowNaturalSort($this->allowNaturalSort);
+ }
+}
\ No newline at end of file
diff --git a/src/EventListener/ConsoleEnsureWebserverUserListener.php b/src/EventListener/ConsoleEnsureWebserverUserListener.php
new file mode 100644
index 00000000..7c119304
--- /dev/null
+++ b/src/EventListener/ConsoleEnsureWebserverUserListener.php
@@ -0,0 +1,159 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\EventListener;
+
+use Symfony\Component\Console\ConsoleEvents;
+use Symfony\Component\Console\Event\ConsoleCommandEvent;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
+use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
+
+/**
+ * This event listener is called before any console command is executed and should ensure that the webserver
+ * user is used for all operations (and show a warning if not). This ensures that all files are created with the
+ * correct permissions.
+ * If the console is in non-interactive mode, a warning is shown, but the command is still executed.
+ */
+#[AsEventListener(ConsoleEvents::COMMAND)]
+class ConsoleEnsureWebserverUserListener
+{
+ public function __construct(
+ #[Autowire('%kernel.project_dir%')]
+ private readonly string $project_root)
+ {
+ }
+
+ public function __invoke(ConsoleCommandEvent $event): void
+ {
+ $input = $event->getInput();
+ $io = new SymfonyStyle($event->getInput(), $event->getOutput());
+
+ //Check if we are (not) running as the webserver user
+ $webserver_user = $this->getWebserverUser();
+ $running_user = $this->getRunningUser();
+
+ //Check if we are trying to run as root
+ if ($this->isRunningAsRoot()) {
+ //If the COMPOSER_ALLOW_SUPERUSER environment variable is set, we allow running as root
+ if ($_SERVER['COMPOSER_ALLOW_SUPERUSER'] ?? false) {
+ return;
+ }
+
+ $io->warning('You are running this command as root. This is not recommended, as it can cause permission problems. Please run this command as the webserver user "'. ($webserver_user ?? '??') . '" instead.');
+ $io->info('You might have already caused permission problems by running this command as wrong user. If you encounter issues with Part-DB, delete the var/cache directory completely and let it be recreated by Part-DB.');
+ if ($input->isInteractive() && !$io->confirm('Do you want to continue?', false)) {
+ $event->disableCommand();
+ }
+
+ return;
+ }
+
+ if ($webserver_user !== null && $running_user !== null && $webserver_user !== $running_user) {
+ $io->warning('You are running this command as the user "' . $running_user . '". This is not recommended, as it can cause permission problems. Please run this command as the webserver user "' . $webserver_user . '" instead.');
+ $io->info('You might have already caused permission problems by running this command as wrong user. If you encounter issues with Part-DB, delete the var/cache directory completely and let it be recreated by Part-DB.');
+ if ($input->isInteractive() && !$io->confirm('Do you want to continue?', false)) {
+ $event->disableCommand();
+ }
+
+ return;
+ }
+ }
+
+ /** @noinspection PhpUndefinedFunctionInspection */
+ private function isRunningAsRoot(): bool
+ {
+ //If we are on windows, we can't run as root
+ if (PHP_OS_FAMILY === 'Windows') {
+ return false;
+ }
+
+ //Try to use the posix extension if available (Linux)
+ if (function_exists('posix_geteuid')) {
+ //Check if the current user is root
+ return posix_geteuid() === 0;
+ }
+
+ //Otherwise we can't determine the username
+ return false;
+ }
+
+ /**
+ * Determines the username of the user who started the current script if possible.
+ * Returns null if the username could not be determined.
+ * @return string|null
+ * @noinspection PhpUndefinedFunctionInspection
+ */
+ private function getRunningUser(): ?string
+ {
+ //Try to use the posix extension if available (Linux)
+ if (function_exists('posix_geteuid') && function_exists('posix_getpwuid')) {
+ $id = posix_geteuid();
+
+ $user = posix_getpwuid($id);
+ //Try to get the username from the posix extension or return the id
+ return $user['name'] ?? ("ID: " . $id);
+ }
+
+ //Otherwise we can't determine the username
+ return $_SERVER['USERNAME'] ?? $_SERVER['USER'] ?? null;
+ }
+
+ private function getWebserverUser(): ?string
+ {
+ //Determine the webserver user, by checking who owns the uploads/ directory
+ $path_to_check = $this->project_root . '/uploads/';
+
+ //Determine the owner of this directory
+ if (!is_dir($path_to_check)) {
+ return null;
+ }
+
+ //If we are on windows we need some special logic
+ if (PHP_OS_FAMILY === 'Windows') {
+ //If we have the COM extension available, we can use it to determine the owner
+ if (extension_loaded('com_dotnet')) {
+ /** @noinspection PhpUndefinedClassInspection */
+ $su = new \COM("ADsSecurityUtility"); // Call interface
+ //@phpstan-ignore-next-line
+ $securityInfo = $su->GetSecurityDescriptor($path_to_check, 1, 1); // Call method
+ return $securityInfo->owner; // Get file owner
+ }
+
+ //Otherwise we can't determine the owner
+ return null;
+ }
+
+ //When we are on a POSIX system, we can use the fileowner function
+ $owner = fileowner($path_to_check);
+
+ if (function_exists('posix_getpwuid')) {
+ $user = posix_getpwuid($owner);
+ //Try to get the username from the posix extension or return the id
+ return $user['name'] ?? ("ID: " . $owner);
+ }
+
+ return null;
+ }
+
+}
\ No newline at end of file
diff --git a/src/EventListener/DisallowSearchEngineIndexingRequestListener.php b/src/EventListener/DisallowSearchEngineIndexingRequestListener.php
new file mode 100644
index 00000000..b969b6b2
--- /dev/null
+++ b/src/EventListener/DisallowSearchEngineIndexingRequestListener.php
@@ -0,0 +1,54 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\EventListener;
+
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
+use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
+use Symfony\Component\HttpKernel\Event\ResponseEvent;
+
+#[AsEventListener]
+class DisallowSearchEngineIndexingRequestListener
+{
+ private const HEADER_NAME = 'X-Robots-Tag';
+
+ private readonly bool $enabled;
+
+ public function __construct(#[Autowire(param: 'partdb.demo_mode')] bool $demo_mode)
+ {
+ // Disable this listener in demo mode
+ $this->enabled = !$demo_mode;
+ }
+
+ public function __invoke(ResponseEvent $event): void
+ {
+ //Skip if disabled
+ if (!$this->enabled) {
+ return;
+ }
+
+ if (!$event->getResponse()->headers->has(self::HEADER_NAME)) {
+ $event->getResponse()->headers->set(self::HEADER_NAME, 'noindex');
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/EventSubscriber/LogSystem/EventLoggerSubscriber.php b/src/EventListener/LogSystem/EventLoggerListener.php
similarity index 81%
rename from src/EventSubscriber/LogSystem/EventLoggerSubscriber.php
rename to src/EventListener/LogSystem/EventLoggerListener.php
index 1f38c66c..6fe3d8dc 100644
--- a/src/EventSubscriber/LogSystem/EventLoggerSubscriber.php
+++ b/src/EventListener/LogSystem/EventLoggerListener.php
@@ -20,7 +20,7 @@
declare(strict_types=1);
-namespace App\EventSubscriber\LogSystem;
+namespace App\EventListener\LogSystem;
use App\Entity\Attachments\Attachment;
use App\Entity\Base\AbstractDBElement;
@@ -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;
@@ -37,7 +38,7 @@ use App\Entity\UserSystem\User;
use App\Services\LogSystem\EventCommentHelper;
use App\Services\LogSystem\EventLogger;
use App\Services\LogSystem\EventUndoHelper;
-use Doctrine\Common\EventSubscriber;
+use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
@@ -47,12 +48,15 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Serializer\SerializerInterface;
/**
- * This event subscriber write to event log when entities are changed, removed, created.
+ * This event subscriber writes to the event log when entities are changed, removed, created.
*/
-class EventLoggerSubscriber implements EventSubscriber
+#[AsDoctrineListener(event: Events::onFlush)]
+#[AsDoctrineListener(event: Events::postPersist)]
+#[AsDoctrineListener(event: Events::postFlush)]
+class EventLoggerListener
{
/**
- * @var array The given fields will not be saved, because they contain sensitive informations
+ * @var array The given fields will not be saved, because they contain sensitive information
*/
protected const FIELD_BLACKLIST = [
User::class => ['password', 'need_pw_change', 'googleAuthenticatorSecret', 'backupCodes', 'trustedDeviceCookieVersion', 'pw_reset_token', 'backupCodesGenerationDate'],
@@ -70,34 +74,19 @@ class EventLoggerSubscriber implements EventSubscriber
];
protected const MAX_STRING_LENGTH = 2000;
+ protected bool $save_new_data;
- protected EventLogger $logger;
- protected SerializerInterface $serializer;
- protected EventCommentHelper $eventCommentHelper;
- protected EventUndoHelper $eventUndoHelper;
- protected bool $save_changed_fields;
- protected bool $save_changed_data;
- protected bool $save_removed_data;
- protected PropertyAccessorInterface $propertyAccessor;
-
- public function __construct(EventLogger $logger, SerializerInterface $serializer, EventCommentHelper $commentHelper,
- bool $save_changed_fields, bool $save_changed_data, bool $save_removed_data, PropertyAccessorInterface $propertyAccessor,
- EventUndoHelper $eventUndoHelper)
+ public function __construct(protected EventLogger $logger, protected SerializerInterface $serializer, protected EventCommentHelper $eventCommentHelper,
+ protected bool $save_changed_fields, protected bool $save_changed_data, protected bool $save_removed_data, bool $save_new_data,
+ protected PropertyAccessorInterface $propertyAccessor, protected EventUndoHelper $eventUndoHelper)
{
- $this->logger = $logger;
- $this->serializer = $serializer;
- $this->eventCommentHelper = $commentHelper;
- $this->propertyAccessor = $propertyAccessor;
- $this->eventUndoHelper = $eventUndoHelper;
-
- $this->save_changed_fields = $save_changed_fields;
- $this->save_changed_data = $save_changed_data;
- $this->save_removed_data = $save_removed_data;
+ //This option only makes sense if save_changed_data is true
+ $this->save_new_data = $save_new_data && $save_changed_data;
}
public function onFlush(OnFlushEventArgs $eventArgs): void
{
- $em = $eventArgs->getEntityManager();
+ $em = $eventArgs->getObjectManager();
$uow = $em->getUnitOfWork();
/*
@@ -128,7 +117,7 @@ class EventLoggerSubscriber implements EventSubscriber
public function postPersist(LifecycleEventArgs $args): void
{
- //Create an log entry, we have to do this post persist, cause we have to know the ID
+ //Create a log entry, we have to do this post persist, because we have to know the ID
/** @var AbstractDBElement $entity */
$entity = $args->getObject();
@@ -150,17 +139,18 @@ class EventLoggerSubscriber implements EventSubscriber
$log->setTargetElementID($undoEvent->getDeletedElementID());
}
}
+
$this->logger->log($log);
}
}
public function postFlush(PostFlushEventArgs $args): void
{
- $em = $args->getEntityManager();
+ $em = $args->getObjectManager();
$uow = $em->getUnitOfWork();
- // If the we have added any ElementCreatedLogEntries added in postPersist, we flush them here.
+ // If we have added any ElementCreatedLogEntries added in postPersist, we flush them here.
$uow->computeChangeSets();
- if ($uow->hasPendingInsertions() || !empty($uow->getScheduledEntityUpdates())) {
+ if ($uow->hasPendingInsertions() || $uow->getScheduledEntityUpdates() !== []) {
$em->flush();
}
@@ -177,7 +167,7 @@ class EventLoggerSubscriber implements EventSubscriber
public function hasFieldRestrictions(AbstractDBElement $element): bool
{
foreach (array_keys(static::FIELD_BLACKLIST) as $class) {
- if (is_a($element, $class)) {
+ if ($element instanceof $class) {
return true;
}
}
@@ -191,24 +181,15 @@ class EventLoggerSubscriber implements EventSubscriber
public function shouldFieldBeSaved(AbstractDBElement $element, string $field_name): bool
{
foreach (static::FIELD_BLACKLIST as $class => $blacklist) {
- if (is_a($element, $class) && in_array($field_name, $blacklist, true)) {
+ if ($element instanceof $class && in_array($field_name, $blacklist, true)) {
return false;
}
}
- //By default allow every field.
+ //By default, allow every field.
return true;
}
- public function getSubscribedEvents(): array
- {
- return[
- Events::onFlush,
- Events::postPersist,
- Events::postFlush,
- ];
- }
-
protected function logElementDeleted(AbstractDBElement $entity, EntityManagerInterface $em): void
{
$log = new ElementDeletedLogEntry($entity);
@@ -227,11 +208,11 @@ class EventLoggerSubscriber implements EventSubscriber
//Check if we have to log CollectionElementDeleted entries
if ($this->save_changed_data) {
- $metadata = $em->getClassMetadata(get_class($entity));
+ $metadata = $em->getClassMetadata($entity::class);
$mappings = $metadata->getAssociationMappings();
//Check if class is whitelisted for CollectionElementDeleted entry
foreach (static::TRIGGER_ASSOCIATION_LOG_WHITELIST as $class => $whitelist) {
- if (is_a($entity, $class)) {
+ if ($entity instanceof $class) {
//Check names
foreach ($mappings as $field => $mapping) {
if (in_array($field, $whitelist, true)) {
@@ -257,7 +238,7 @@ class EventLoggerSubscriber implements EventSubscriber
$changeSet = $uow->getEntityChangeSet($entity);
//Skip log entry, if only the lastModified field has changed...
- if (isset($changeSet['lastModified']) && count($changeSet)) {
+ if (count($changeSet) === 1 && isset($changeSet['lastModified'])) {
return;
}
@@ -301,8 +282,25 @@ class EventLoggerSubscriber implements EventSubscriber
}, ARRAY_FILTER_USE_BOTH);
}
+ /**
+ * Restrict the length of every string in the given array to MAX_STRING_LENGTH, to save memory in the case of very
+ * long strings (e.g. images in notes)
+ */
+ protected function fieldLengthRestrict(array $fields): array
+ {
+ return array_map(
+ static function ($value) {
+ if (is_string($value)) {
+ return mb_strimwidth($value, 0, self::MAX_STRING_LENGTH, '...');
+ }
+
+ return $value;
+ }, $fields);
+ }
+
protected function saveChangeSet(AbstractDBElement $entity, AbstractLogEntry $logEntry, EntityManagerInterface $em, bool $element_deleted = false): void
{
+ $new_data = null;
$uow = $em->getUnitOfWork();
if (!$logEntry instanceof ElementEditedLogEntry && !$logEntry instanceof ElementDeletedLogEntry) {
@@ -314,20 +312,24 @@ class EventLoggerSubscriber implements EventSubscriber
} else { //Otherwise we have to get it from entity changeset
$changeSet = $uow->getEntityChangeSet($entity);
$old_data = array_combine(array_keys($changeSet), array_column($changeSet, 0));
+ //If save_new_data is enabled, we extract it from the change set
+ if ($this->save_new_data) {
+ $new_data = array_combine(array_keys($changeSet), array_column($changeSet, 1));
+ }
}
$old_data = $this->filterFieldRestrictions($entity, $old_data);
//Restrict length of string fields, to save memory...
- $old_data = array_map(
- static function ($value) {
- if (is_string($value)) {
- return mb_strimwidth($value, 0, self::MAX_STRING_LENGTH, '...');
- }
-
- return $value;
- }, $old_data);
+ $old_data = $this->fieldLengthRestrict($old_data);
$logEntry->setOldData($old_data);
+
+ if ($new_data !== [] && $new_data !== null) {
+ $new_data = $this->filterFieldRestrictions($entity, $new_data);
+ $new_data = $this->fieldLengthRestrict($new_data);
+
+ $logEntry->setNewData($new_data);
+ }
}
/**
@@ -337,6 +339,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/EventSubscriber/LogSystem/LogDBMigrationSubscriber.php b/src/EventListener/LogSystem/LogDBMigrationListener.php
similarity index 81%
rename from src/EventSubscriber/LogSystem/LogDBMigrationSubscriber.php
rename to src/EventListener/LogSystem/LogDBMigrationListener.php
index 610c0b5e..c8b60e18 100644
--- a/src/EventSubscriber/LogSystem/LogDBMigrationSubscriber.php
+++ b/src/EventListener/LogSystem/LogDBMigrationListener.php
@@ -20,11 +20,11 @@
declare(strict_types=1);
-namespace App\EventSubscriber\LogSystem;
+namespace App\EventListener\LogSystem;
use App\Entity\LogSystem\DatabaseUpdatedLogEntry;
use App\Services\LogSystem\EventLogger;
-use Doctrine\Common\EventSubscriber;
+use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Migrations\DependencyFactory;
use Doctrine\Migrations\Event\MigrationsEventArgs;
use Doctrine\Migrations\Events;
@@ -32,18 +32,15 @@ use Doctrine\Migrations\Events;
/**
* This subscriber logs databaseMigrations to Event log.
*/
-class LogDBMigrationSubscriber implements EventSubscriber
+#[AsDoctrineListener(event: Events::onMigrationsMigrated)]
+#[AsDoctrineListener(event: Events::onMigrationsMigrating)]
+class LogDBMigrationListener
{
protected ?string $old_version = null;
protected ?string $new_version = null;
- protected EventLogger $eventLogger;
- protected DependencyFactory $dependencyFactory;
-
- public function __construct(EventLogger $eventLogger, DependencyFactory $dependencyFactory)
+ public function __construct(protected EventLogger $eventLogger, protected DependencyFactory $dependencyFactory)
{
- $this->eventLogger = $eventLogger;
- $this->dependencyFactory = $dependencyFactory;
}
public function onMigrationsMigrated(MigrationsEventArgs $args): void
@@ -60,13 +57,13 @@ class LogDBMigrationSubscriber implements EventSubscriber
$this->new_version = (string) $aliasResolver->resolveVersionAlias('current');
//After everything is done, write the results to DB log
- $this->old_version = empty($this->old_version) ? 'legacy/empty' : $this->old_version;
- $this->new_version = empty($this->new_version) ? 'unknown' : $this->new_version;
+ $this->old_version = $this->old_version === null || $this->old_version === '' ? 'legacy/empty' : $this->old_version;
+ $this->new_version = $this->new_version === '' ? 'unknown' : $this->new_version;
try {
$log = new DatabaseUpdatedLogEntry($this->old_version, $this->new_version);
$this->eventLogger->logAndFlush($log);
- } catch (\Throwable $exception) {
+ } catch (\Throwable) {
//Ignore any exception occuring here...
}
}
diff --git a/src/EventSubscriber/LogSystem/LogAccessDeniedSubscriber.php b/src/EventSubscriber/LogSystem/LogAccessDeniedSubscriber.php
index a2b9f1ff..abe2f9ba 100644
--- a/src/EventSubscriber/LogSystem/LogAccessDeniedSubscriber.php
+++ b/src/EventSubscriber/LogSystem/LogAccessDeniedSubscriber.php
@@ -49,15 +49,12 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
/**
- * Write to event log when a user tries to access an forbidden page and recevies an 403 Access Denied message.
+ * Write to event log when a user tries to access a forbidden page and receives an 403 Access Denied message.
*/
class LogAccessDeniedSubscriber implements EventSubscriberInterface
{
- private EventLogger $logger;
-
- public function __construct(EventLogger $logger)
+ public function __construct(private readonly EventLogger $logger)
{
- $this->logger = $logger;
}
public function onKernelException(ExceptionEvent $event): void
diff --git a/src/EventSubscriber/LogSystem/LogoutLoggerListener.php b/src/EventSubscriber/LogSystem/LogLogoutEventSubscriber.php
similarity index 70%
rename from src/EventSubscriber/LogSystem/LogoutLoggerListener.php
rename to src/EventSubscriber/LogSystem/LogLogoutEventSubscriber.php
index 3b197b38..7e6d2da7 100644
--- a/src/EventSubscriber/LogSystem/LogoutLoggerListener.php
+++ b/src/EventSubscriber/LogSystem/LogLogoutEventSubscriber.php
@@ -22,6 +22,8 @@ declare(strict_types=1);
namespace App\EventSubscriber\LogSystem;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use App\Entity\LogSystem\UserLogoutLogEntry;
use App\Entity\UserSystem\User;
use App\Services\LogSystem\EventLogger;
@@ -30,27 +32,22 @@ use Symfony\Component\Security\Http\Event\LogoutEvent;
/**
* This handler logs to event log, if a user logs out.
*/
-class LogoutLoggerListener
+final class LogLogoutEventSubscriber implements EventSubscriberInterface
{
- protected EventLogger $logger;
- protected bool $gpdr_compliance;
-
- public function __construct(EventLogger $logger, bool $gpdr_compliance)
+ public function __construct(private readonly EventLogger $logger, private readonly bool $gdpr_compliance)
{
- $this->logger = $logger;
- $this->gpdr_compliance = $gpdr_compliance;
}
- public function __invoke(LogoutEvent $event)
+ public function logLogout(LogoutEvent $event): void
{
$request = $event->getRequest();
$token = $event->getToken();
- if (null === $token) {
+ if (!$token instanceof TokenInterface) {
return;
}
- $log = new UserLogoutLogEntry($request->getClientIp(), $this->gpdr_compliance);
+ $log = new UserLogoutLogEntry($request->getClientIp(), $this->gdpr_compliance);
$user = $token->getUser();
if ($user instanceof User) {
$log->setTargetElement($user);
@@ -58,4 +55,12 @@ class LogoutLoggerListener
$this->logger->logAndFlush($log);
}
+ /**
+ * @return array
+ */
+ public static function getSubscribedEvents(): array
+ {
+ return [LogoutEvent::class => 'logLogout'];
+ }
+
}
diff --git a/src/EventSubscriber/LogSystem/SecurityEventLoggerSubscriber.php b/src/EventSubscriber/LogSystem/SecurityEventLoggerSubscriber.php
index 78402633..7880a41d 100644
--- a/src/EventSubscriber/LogSystem/SecurityEventLoggerSubscriber.php
+++ b/src/EventSubscriber/LogSystem/SecurityEventLoggerSubscriber.php
@@ -41,6 +41,7 @@ declare(strict_types=1);
namespace App\EventSubscriber\LogSystem;
+use Symfony\Component\HttpFoundation\Request;
use App\Entity\LogSystem\SecurityEventLogEntry;
use App\Events\SecurityEvent;
use App\Events\SecurityEvents;
@@ -49,19 +50,12 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
/**
- * This subscriber writes entries to log if an security related event happens (e.g. the user changes its password).
+ * This subscriber writes entries to log if a security related event happens (e.g. the user changes its password).
*/
final class SecurityEventLoggerSubscriber implements EventSubscriberInterface
{
- private RequestStack $requestStack;
- private bool $gpdr_compliant;
- private EventLogger $eventLogger;
-
- public function __construct(RequestStack $requestStack, EventLogger $eventLogger, bool $gpdr_compliance)
+ public function __construct(private readonly RequestStack $requestStack, private readonly EventLogger $eventLogger, private readonly bool $gdpr_compliance)
{
- $this->requestStack = $requestStack;
- $this->gpdr_compliant = $gpdr_compliance;
- $this->eventLogger = $eventLogger;
}
public static function getSubscribedEvents(): array
@@ -76,6 +70,7 @@ final class SecurityEventLoggerSubscriber implements EventSubscriberInterface
SecurityEvents::GOOGLE_DISABLED => 'google_disabled',
SecurityEvents::GOOGLE_ENABLED => 'google_enabled',
SecurityEvents::TFA_ADMIN_RESET => 'tfa_admin_reset',
+ SecurityEvents::USER_IMPERSONATED => 'user_impersonated',
];
}
@@ -109,6 +104,11 @@ final class SecurityEventLoggerSubscriber implements EventSubscriberInterface
$this->addLog(SecurityEvents::U2F_REMOVED, $event);
}
+ public function user_impersonated(SecurityEvent $event): void
+ {
+ $this->addLog(SecurityEvents::USER_IMPERSONATED, $event);
+ }
+
public function u2f_added(SecurityEvent $event): void
{
$this->addLog(SecurityEvents::U2F_ADDED, $event);
@@ -126,14 +126,14 @@ final class SecurityEventLoggerSubscriber implements EventSubscriberInterface
private function addLog(string $type, SecurityEvent $event): void
{
- $anonymize = $this->gpdr_compliant;
+ $anonymize = $this->gdpr_compliance;
$request = $this->requestStack->getCurrentRequest();
- if (null !== $request) {
+ if ($request instanceof Request) {
$ip = $request->getClientIp() ?? 'unknown';
} else {
$ip = 'Console';
- //Dont try to apply IP filter rules to non numeric string
+ //Don't try to apply IP filter rules to non-numeric string
$anonymize = false;
}
diff --git a/src/EventSubscriber/RedirectToHttpsSubscriber.php b/src/EventSubscriber/RedirectToHttpsSubscriber.php
new file mode 100644
index 00000000..7089109e
--- /dev/null
+++ b/src/EventSubscriber/RedirectToHttpsSubscriber.php
@@ -0,0 +1,72 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\EventSubscriber;
+
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\HttpKernel\Event\RequestEvent;
+use Symfony\Component\HttpKernel\KernelEvents;
+use Symfony\Component\Security\Http\HttpUtils;
+
+/**
+ * The purpose of this event listener is (if enabled) to redirect all requests to https.
+ */
+final class RedirectToHttpsSubscriber implements EventSubscriberInterface
+{
+
+ public function __construct(
+ #[Autowire('%env(bool:REDIRECT_TO_HTTPS)%')]
+ private readonly bool $enabled,
+ private readonly HttpUtils $httpUtils)
+ {
+ }
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ KernelEvents::REQUEST => 'onKernelRequest',
+ ];
+ }
+
+ public function onKernelRequest(RequestEvent $event): void
+ {
+ //If the feature is disabled, or we are not the main request, we do nothing
+ if (!$this->enabled || !$event->isMainRequest()) {
+ return;
+ }
+
+
+ $request = $event->getRequest();
+
+ //If the request is already https, we do nothing
+ if ($request->isSecure()) {
+ return;
+ }
+
+
+ //Change the request to https
+ $new_url = str_replace('http://', 'https://' ,$request->getUri());
+ $event->setResponse($this->httpUtils->createRedirectResponse($event->getRequest(), $new_url));
+ }
+}
\ No newline at end of file
diff --git a/src/EventSubscriber/SetMailFromSubscriber.php b/src/EventSubscriber/SetMailFromSubscriber.php
index 6c9f563c..3675c487 100644
--- a/src/EventSubscriber/SetMailFromSubscriber.php
+++ b/src/EventSubscriber/SetMailFromSubscriber.php
@@ -32,13 +32,8 @@ use Symfony\Component\Mime\Email;
*/
final class SetMailFromSubscriber implements EventSubscriberInterface
{
- private string $email;
- private string $name;
-
- public function __construct(string $email, string $name)
+ public function __construct(private readonly string $email, private readonly string $name)
{
- $this->email = $email;
- $this->name = $name;
}
public function onMessage(MessageEvent $event): void
diff --git a/src/EventSubscriber/SwitchUserEventSubscriber.php b/src/EventSubscriber/SwitchUserEventSubscriber.php
new file mode 100644
index 00000000..b68f6b4f
--- /dev/null
+++ b/src/EventSubscriber/SwitchUserEventSubscriber.php
@@ -0,0 +1,64 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\EventSubscriber;
+
+use App\Entity\UserSystem\User;
+use App\Events\SecurityEvent;
+use App\Events\SecurityEvents;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
+use Symfony\Component\Security\Http\Event\SwitchUserEvent;
+use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
+
+class SwitchUserEventSubscriber implements EventSubscriberInterface
+{
+
+ public function __construct(private readonly EventDispatcherInterface $eventDispatcher)
+ {
+ }
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ 'security.switch_user' => 'onSwitchUser',
+ ];
+ }
+
+ public function onSwitchUser(SwitchUserEvent $event): void
+ {
+ $target_user = $event->getTargetUser();
+ // We can only handle User objects
+ if (!$target_user instanceof User) {
+ return;
+ }
+
+ //We are only interested in impersonation (not unimpersonation)
+ if (!$event->getToken() instanceof SwitchUserToken) {
+ return;
+ }
+
+ $security_event = new SecurityEvent($target_user);
+ $this->eventDispatcher->dispatch($security_event, SecurityEvents::USER_IMPERSONATED);
+ }
+}
\ No newline at end of file
diff --git a/src/EventSubscriber/SymfonyDebugToolbarSubscriber.php b/src/EventSubscriber/SymfonyDebugToolbarSubscriber.php
index f6f4fc25..6f17e399 100644
--- a/src/EventSubscriber/SymfonyDebugToolbarSubscriber.php
+++ b/src/EventSubscriber/SymfonyDebugToolbarSubscriber.php
@@ -26,15 +26,12 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
/**
- * This subscriber sets an Header in Debug mode that signals the Symfony Profiler to also update on Ajax requests.
+ * This subscriber sets a Header in Debug mode that signals the Symfony Profiler to also update on Ajax requests.
*/
final class SymfonyDebugToolbarSubscriber implements EventSubscriberInterface
{
- private bool $kernel_debug;
-
- public function __construct(bool $kernel_debug)
+ public function __construct(private readonly bool $kernel_debug_enabled)
{
- $this->kernel_debug = $kernel_debug;
}
/**
@@ -62,7 +59,7 @@ final class SymfonyDebugToolbarSubscriber implements EventSubscriberInterface
public function onKernelResponse(ResponseEvent $event): void
{
- if (!$this->kernel_debug) {
+ if (!$this->kernel_debug_enabled) {
return;
}
diff --git a/src/EventSubscriber/UserSystem/LoginSuccessSubscriber.php b/src/EventSubscriber/UserSystem/LoginSuccessSubscriber.php
index 00b99fca..d67a8e14 100644
--- a/src/EventSubscriber/UserSystem/LoginSuccessSubscriber.php
+++ b/src/EventSubscriber/UserSystem/LoginSuccessSubscriber.php
@@ -26,43 +26,36 @@ use App\Entity\LogSystem\UserLoginLogEntry;
use App\Entity\UserSystem\User;
use App\Services\LogSystem\EventLogger;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface;
+use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\Session;
-use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
use Symfony\Component\Security\Http\SecurityEvents;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
- * This event listener shows an login successful flash to the user after login and write the login to event log.
+ * This event listener shows a login successful flash to the user after login and write the login to event log.
*/
final class LoginSuccessSubscriber implements EventSubscriberInterface
{
- private TranslatorInterface $translator;
- private FlashBagInterface $flashBag;
- private EventLogger $eventLogger;
- private bool $gpdr_compliance;
-
- public function __construct(TranslatorInterface $translator, SessionInterface $session, EventLogger $eventLogger, bool $gpdr_compliance)
+ public function __construct(private readonly TranslatorInterface $translator, private readonly RequestStack $requestStack, private readonly EventLogger $eventLogger, private readonly bool $gdpr_compliance)
{
- /** @var Session $session */
- $this->translator = $translator;
- $this->flashBag = $session->getFlashBag();
- $this->eventLogger = $eventLogger;
- $this->gpdr_compliance = $gpdr_compliance;
}
public function onLogin(InteractiveLoginEvent $event): void
{
$ip = $event->getRequest()->getClientIp();
- $log = new UserLoginLogEntry($ip, $this->gpdr_compliance);
+ $log = new UserLoginLogEntry($ip, $this->gdpr_compliance);
$user = $event->getAuthenticationToken()->getUser();
- if ($user instanceof User) {
+ if ($user instanceof User && $user->getID()) {
$log->setTargetElement($user);
+ $this->eventLogger->logAndFlush($log);
}
- $this->eventLogger->logAndFlush($log);
- $this->flashBag->add('notice', $this->translator->trans('flash.login_successful'));
+ /** @var Session $session */
+ $session = $this->requestStack->getSession();
+ $flashBag = $session->getFlashBag();
+
+ $flashBag->add('notice', $this->translator->trans('flash.login_successful'));
}
/**
diff --git a/src/EventSubscriber/UserSystem/LogoutDisabledUserSubscriber.php b/src/EventSubscriber/UserSystem/LogoutDisabledUserSubscriber.php
index 2ab3f8f7..33c5e919 100644
--- a/src/EventSubscriber/UserSystem/LogoutDisabledUserSubscriber.php
+++ b/src/EventSubscriber/UserSystem/LogoutDisabledUserSubscriber.php
@@ -22,35 +22,29 @@ declare(strict_types=1);
namespace App\EventSubscriber\UserSystem;
+use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\UserSystem\User;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
-use Symfony\Component\Security\Core\Security;
/**
- * This subscriber is used to log out a disabled user, as soon as he to do an request.
- * It is not possible for him to login again, afterwards.
+ * This subscriber is used to log out a disabled user, as soon as he to do a request.
+ * It is not possible for him to login again, afterward.
*/
final class LogoutDisabledUserSubscriber implements EventSubscriberInterface
{
- private Security $security;
- private UrlGeneratorInterface $urlGenerator;
-
- public function __construct(Security $security, UrlGeneratorInterface $urlGenerator)
+ public function __construct(private readonly Security $security, private readonly UrlGeneratorInterface $urlGenerator)
{
- $this->security = $security;
-
- $this->urlGenerator = $urlGenerator;
}
public function onRequest(RequestEvent $event): void
{
$user = $this->security->getUser();
if ($user instanceof User && $user->isDisabled()) {
- //Redirect to login
+ //Redirect to log in
$response = new RedirectResponse($this->urlGenerator->generate('logout'));
$event->setResponse($response);
}
diff --git a/src/EventSubscriber/UserSystem/PasswordChangeNeededSubscriber.php b/src/EventSubscriber/UserSystem/PasswordChangeNeededSubscriber.php
index 98020f03..2eb32436 100644
--- a/src/EventSubscriber/UserSystem/PasswordChangeNeededSubscriber.php
+++ b/src/EventSubscriber/UserSystem/PasswordChangeNeededSubscriber.php
@@ -22,14 +22,14 @@ declare(strict_types=1);
namespace App\EventSubscriber\UserSystem;
+use Symfony\Bundle\SecurityBundle\Security;
+use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface;
+use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
-use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
-use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Http\HttpUtils;
/**
@@ -55,16 +55,9 @@ final class PasswordChangeNeededSubscriber implements EventSubscriberInterface
* @var string The route the user will redirected to, if he needs to change this password
*/
public const REDIRECT_TARGET = 'user_settings';
- private Security $security;
- private FlashBagInterface $flashBag;
- private HttpUtils $httpUtils;
- public function __construct(Security $security, SessionInterface $session, HttpUtils $httpUtils)
+ public function __construct(private readonly Security $security, private readonly HttpUtils $httpUtils)
{
- /** @var Session $session */
- $this->security = $security;
- $this->flashBag = $session->getFlashBag();
- $this->httpUtils = $httpUtils;
}
/**
@@ -84,8 +77,13 @@ final class PasswordChangeNeededSubscriber implements EventSubscriberInterface
return;
}
+ //If the user is impersonated, we don't need to redirect him
+ if ($this->security->isGranted('IS_IMPERSONATOR')) {
+ return;
+ }
+
//Abort if we dont need to redirect the user.
- if (!$user->isNeedPwChange() && !static::TFARedirectNeeded($user)) {
+ if (!$user->isNeedPwChange() && !self::TFARedirectNeeded($user, $request)) {
return;
}
@@ -99,17 +97,21 @@ final class PasswordChangeNeededSubscriber implements EventSubscriberInterface
/* Dont redirect tree endpoints, as this would cause trouble and creates multiple flash
warnigs for one page reload */
- if (false !== strpos($request->getUri(), '/tree/')) {
+ if (str_contains($request->getUri(), '/tree/')) {
return;
}
+ /** @var Session $session */
+ $session = $request->getSession();
+ $flashBag = $session->getFlashBag();
+
//Show appropriate message to user about the reason he was redirected
if ($user->isNeedPwChange()) {
- $this->flashBag->add('warning', 'user.pw_change_needed.flash');
+ $flashBag->add('warning', 'user.pw_change_needed.flash');
}
- if (static::TFARedirectNeeded($user)) {
- $this->flashBag->add('warning', 'user.2fa_needed.flash');
+ if (self::TFARedirectNeeded($user, $request)) {
+ $flashBag->add('warning', 'user.2fa_needed.flash');
}
$event->setResponse($this->httpUtils->createRedirectResponse($request, static::REDIRECT_TARGET));
@@ -119,16 +121,35 @@ final class PasswordChangeNeededSubscriber implements EventSubscriberInterface
* Check if a redirect because of a missing 2FA method is needed.
* That is the case if the group of the user enforces 2FA, but the user has neither Google Authenticator nor an
* U2F key setup.
+ * The result is cached for some minutes in the session to prevent unnecessary database queries.
*
* @param User $user the user for which should be checked if it needs to be redirected
*
* @return bool true if the user needs to be redirected
*/
- public static function TFARedirectNeeded(User $user): bool
+ public static function TFARedirectNeeded(User $user, Request $request): bool
{
+ //Check if when we have checked the user the last time
+ $session = $request->getSession();
+ $last_check = $session->get('tfa_redirect_check', 0);
+
+ //If we have checked the user already in the last 10 minutes, we don't need to check it again
+ if ($last_check > time() - 600) {
+ return false;
+ }
+
+ //Otherwise we check the user again
+
$tfa_enabled = $user->isWebAuthnAuthenticatorEnabled() || $user->isGoogleAuthenticatorEnabled();
- return null !== $user->getGroup() && $user->getGroup()->isEnforce2FA() && !$tfa_enabled;
+ $result = $user->getGroup() instanceof Group && $user->getGroup()->isEnforce2FA() && !$tfa_enabled;
+
+ //If no redirect is needed, we set the last check time to now
+ if (!$result) {
+ $session->set('tfa_redirect_check', time());
+ }
+
+ return $result;
}
public static function getSubscribedEvents(): array
diff --git a/src/EventSubscriber/UserSystem/SetUserTimezoneSubscriber.php b/src/EventSubscriber/UserSystem/SetUserTimezoneSubscriber.php
index c39d4b37..10ecaddf 100644
--- a/src/EventSubscriber/UserSystem/SetUserTimezoneSubscriber.php
+++ b/src/EventSubscriber/UserSystem/SetUserTimezoneSubscriber.php
@@ -23,23 +23,18 @@ declare(strict_types=1);
namespace App\EventSubscriber\UserSystem;
use App\Entity\UserSystem\User;
+use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\KernelEvents;
-use Symfony\Component\Security\Core\Security;
/**
* The purpose of this event listener is to set the timezone to the one preferred by the user.
*/
final class SetUserTimezoneSubscriber implements EventSubscriberInterface
{
- private string $default_timezone;
- private Security $security;
-
- public function __construct(string $timezone, Security $security)
+ public function __construct(private readonly string $default_timezone, private readonly Security $security)
{
- $this->default_timezone = $timezone;
- $this->security = $security;
}
public function setTimeZone(ControllerEvent $event): void
@@ -48,12 +43,12 @@ final class SetUserTimezoneSubscriber implements EventSubscriberInterface
//Check if the user has set a timezone
$user = $this->security->getUser();
- if ($user instanceof User && !empty($user->getTimezone())) {
+ if ($user instanceof User && ($user->getTimezone() !== null && $user->getTimezone() !== '')) {
$timezone = $user->getTimezone();
}
//Fill with default value if needed
- if (null === $timezone && !empty($this->default_timezone)) {
+ if (null === $timezone && $this->default_timezone !== '') {
$timezone = $this->default_timezone;
}
diff --git a/src/EventSubscriber/UserSystem/UpgradePermissionsSchemaSubscriber.php b/src/EventSubscriber/UserSystem/UpgradePermissionsSchemaSubscriber.php
index 7d891df0..613bc6ec 100644
--- a/src/EventSubscriber/UserSystem/UpgradePermissionsSchemaSubscriber.php
+++ b/src/EventSubscriber/UserSystem/UpgradePermissionsSchemaSubscriber.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\EventSubscriber\UserSystem;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\Security\Core\User\UserInterface;
use App\Entity\UserSystem\User;
use App\Services\LogSystem\EventCommentHelper;
use App\Services\UserSystem\PermissionSchemaUpdater;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface;
+use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
-use Symfony\Component\Security\Core\Security;
/**
- * The purpose of this event subscriber is to check if the permission schema of the current user is up to date and upgrade it automatically if needed.
+ * The purpose of this event subscriber is to check if the permission schema of the current user is up-to-date and upgrade it automatically if needed.
*/
class UpgradePermissionsSchemaSubscriber implements EventSubscriberInterface
{
- private Security $security;
- private PermissionSchemaUpdater $permissionSchemaUpdater;
- private EntityManagerInterface $entityManager;
- private FlashBagInterface $flashBag;
- private EventCommentHelper $eventCommentHelper;
-
- public function __construct(Security $security, PermissionSchemaUpdater $permissionSchemaUpdater, EntityManagerInterface $entityManager, FlashBagInterface $flashBag, EventCommentHelper $eventCommentHelper)
+ public function __construct(private readonly Security $security, private readonly PermissionSchemaUpdater $permissionSchemaUpdater, private readonly EntityManagerInterface $entityManager, private readonly EventCommentHelper $eventCommentHelper)
{
- $this->security = $security;
- $this->permissionSchemaUpdater = $permissionSchemaUpdater;
- $this->entityManager = $entityManager;
- $this->flashBag = $flashBag;
- $this->eventCommentHelper = $eventCommentHelper;
}
public function onRequest(RequestEvent $event): void
@@ -57,16 +49,25 @@ class UpgradePermissionsSchemaSubscriber implements EventSubscriberInterface
}
$user = $this->security->getUser();
- if (null === $user) {
+ if (!$user instanceof UserInterface) {
//Retrieve anonymous user
$user = $this->entityManager->getRepository(User::class)->getAnonymousUser();
}
+ /** @var Session $session */
+ $session = $event->getRequest()->getSession();
+ $flashBag = $session->getFlashBag();
+
+ //Check if the user is an instance of User, otherwise we can't upgrade the schema
+ if (!$user instanceof User) {
+ return;
+ }
+
if ($this->permissionSchemaUpdater->isSchemaUpdateNeeded($user)) {
$this->eventCommentHelper->setMessage('Automatic permission schema update');
$this->permissionSchemaUpdater->userUpgradeSchemaRecursively($user);
$this->entityManager->flush();
- $this->flashBag->add('notice', 'user.permissions_schema_updated');
+ $flashBag->add('notice', 'user.permissions_schema_updated');
}
}
@@ -74,4 +75,4 @@ class UpgradePermissionsSchemaSubscriber implements EventSubscriberInterface
{
return [KernelEvents::REQUEST => 'onRequest'];
}
-}
\ No newline at end of file
+}
diff --git a/src/Events/SecurityEvent.php b/src/Events/SecurityEvent.php
index bf8056d3..883460a7 100644
--- a/src/Events/SecurityEvent.php
+++ b/src/Events/SecurityEvent.php
@@ -46,15 +46,12 @@ use Symfony\Contracts\EventDispatcher\Event;
/**
* This event is triggered when something security related to a user happens.
- * For example when the password is reset or the an two factor authentication method was disabled.
+ * For example when the password is reset or the two-factor authentication method was disabled.
*/
class SecurityEvent extends Event
{
- protected User $targetUser;
-
- public function __construct(User $targetUser)
+ public function __construct(protected User $targetUser)
{
- $this->targetUser = $targetUser;
}
/**
diff --git a/src/Events/SecurityEvents.php b/src/Events/SecurityEvents.php
index 6ac1f882..f2e44c6f 100644
--- a/src/Events/SecurityEvents.php
+++ b/src/Events/SecurityEvents.php
@@ -43,13 +43,15 @@ namespace App\Events;
class SecurityEvents
{
- public const PASSWORD_CHANGED = 'security.password_changed';
- public const PASSWORD_RESET = 'security.password_reset';
- public const BACKUP_KEYS_RESET = 'security.backup_keys_reset';
- public const U2F_ADDED = 'security.u2f_added';
- public const U2F_REMOVED = 'security.u2f_removed';
- public const GOOGLE_ENABLED = 'security.google_enabled';
- public const GOOGLE_DISABLED = 'security.google_disabled';
- public const TRUSTED_DEVICE_RESET = 'security.trusted_device_reset';
- public const TFA_ADMIN_RESET = 'security.2fa_admin_reset';
+ final public const PASSWORD_CHANGED = 'security.password_changed';
+ final public const PASSWORD_RESET = 'security.password_reset';
+ final public const BACKUP_KEYS_RESET = 'security.backup_keys_reset';
+ final public const U2F_ADDED = 'security.u2f_added';
+ final public const U2F_REMOVED = 'security.u2f_removed';
+ final public const GOOGLE_ENABLED = 'security.google_enabled';
+ final public const GOOGLE_DISABLED = 'security.google_disabled';
+ final public const TRUSTED_DEVICE_RESET = 'security.trusted_device_reset';
+ final public const TFA_ADMIN_RESET = 'security.2fa_admin_reset';
+
+ final public const USER_IMPERSONATED = 'security.user_impersonated';
}
diff --git a/src/Exceptions/InvalidRegexException.php b/src/Exceptions/InvalidRegexException.php
new file mode 100644
index 00000000..aaa2a897
--- /dev/null
+++ b/src/Exceptions/InvalidRegexException.php
@@ -0,0 +1,74 @@
+.
+ */
+namespace App\Exceptions;
+
+use Doctrine\DBAL\Exception\DriverException;
+use ErrorException;
+
+class InvalidRegexException extends \RuntimeException
+{
+ public function __construct(private readonly ?string $reason = null)
+ {
+ parent::__construct('Invalid regular expression');
+ }
+
+ /**
+ * Returns the reason for the exception (what the regex driver deemed invalid)
+ */
+ public function getReason(): ?string
+ {
+ return $this->reason;
+ }
+
+ /**
+ * Creates a new exception from a driver exception happening, when MySQL encounters an invalid regex
+ */
+ public static function fromDriverException(DriverException $exception): self
+ {
+ //1139 means invalid regex error
+ if ($exception->getCode() !== 1139) {
+ throw new \InvalidArgumentException('The given exception is not a driver exception', 0, $exception);
+ }
+
+ //Reason is the part after the erorr code
+ $reason = preg_replace('/^.*1139 /', '', $exception->getMessage());
+
+ return new self($reason);
+ }
+
+ /**
+ * Creates a new exception from the errorException thrown by mb_ereg
+ */
+ public static function fromMBRegexError(ErrorException $ex): self
+ {
+ //Ensure that the error is really a mb_ereg error
+ if ($ex->getSeverity() !== E_WARNING || !strpos($ex->getMessage(), 'mb_ereg()')) {
+ throw new \InvalidArgumentException('The given exception is not a mb_ereg error', 0, $ex);
+ }
+
+ //Reason is the part after the erorr code
+ $reason = preg_replace('/^.*mb_ereg\(\): /', '', $ex->getMessage());
+
+ return new self($reason);
+ }
+}
diff --git a/src/Exceptions/TwigModeException.php b/src/Exceptions/TwigModeException.php
index adcc86aa..b76d14d3 100644
--- a/src/Exceptions/TwigModeException.php
+++ b/src/Exceptions/TwigModeException.php
@@ -46,8 +46,23 @@ use Twig\Error\Error;
class TwigModeException extends RuntimeException
{
+ private const PROJECT_PATH = __DIR__ . '/../../';
+
public function __construct(?Error $previous = null)
{
parent::__construct($previous->getMessage(), 0, $previous);
}
+
+ /**
+ * Returns the message of this exception, where it is tried to remove any sensitive information (like filepaths).
+ * @return string
+ */
+ public function getSafeMessage(): string
+ {
+ //Resolve project root path
+ $projectPath = realpath(self::PROJECT_PATH);
+
+ //Remove occurrences of the project path from the message
+ return str_replace($projectPath, '[Part-DB Root Folder]', $this->getMessage());
+ }
}
diff --git a/src/Form/AdminPages/AttachmentTypeAdminForm.php b/src/Form/AdminPages/AttachmentTypeAdminForm.php
index 75174279..d777d4d4 100644
--- a/src/Form/AdminPages/AttachmentTypeAdminForm.php
+++ b/src/Form/AdminPages/AttachmentTypeAdminForm.php
@@ -22,21 +22,19 @@ declare(strict_types=1);
namespace App\Form\AdminPages;
+use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Base\AbstractNamedDBElement;
use App\Services\Attachments\FileTypeFilterTools;
+use App\Services\LogSystem\EventCommentNeededHelper;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
-use Symfony\Component\Security\Core\Security;
class AttachmentTypeAdminForm extends BaseEntityAdminForm
{
- protected FileTypeFilterTools $filterTools;
-
- public function __construct(Security $security, FileTypeFilterTools $filterTools)
+ public function __construct(Security $security, protected FileTypeFilterTools $filterTools, EventCommentNeededHelper $eventCommentNeededHelper)
{
- $this->filterTools = $filterTools;
- parent::__construct($security);
+ parent::__construct($security, $eventCommentNeededHelper);
}
protected function additionalFormElements(FormBuilderInterface $builder, array $options, AbstractNamedDBElement $entity): void
@@ -57,12 +55,8 @@ class AttachmentTypeAdminForm extends BaseEntityAdminForm
//Normalize data before writing it to database
$builder->get('filetype_filter')->addViewTransformer(new CallbackTransformer(
- static function ($value) {
- return $value;
- },
- function ($value) {
- return $this->filterTools->normalizeFilterString($value);
- }
+ static fn($value) => $value,
+ fn($value) => $this->filterTools->normalizeFilterString($value)
));
}
}
diff --git a/src/Form/AdminPages/BaseEntityAdminForm.php b/src/Form/AdminPages/BaseEntityAdminForm.php
index a28e0211..d1a0ffd0 100644
--- a/src/Form/AdminPages/BaseEntityAdminForm.php
+++ b/src/Form/AdminPages/BaseEntityAdminForm.php
@@ -22,17 +22,19 @@ 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;
use App\Entity\LabelSystem\LabelProfile;
-use App\Entity\Parameters\AbstractParameter;
use App\Form\AttachmentFormType;
use App\Form\ParameterType;
use App\Form\Type\MasterPictureAttachmentType;
use App\Form\Type\RichTextEditorType;
use App\Form\Type\StructuralEntityType;
-use FOS\CKEditorBundle\Form\Type\CKEditorType;
-use function get_class;
+use App\Services\LogSystem\EventCommentNeededHelper;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
@@ -41,15 +43,11 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
-use Symfony\Component\Security\Core\Security;
class BaseEntityAdminForm extends AbstractType
{
- protected Security $security;
-
- public function __construct(Security $security)
+ public function __construct(protected Security $security, protected EventCommentNeededHelper $eventCommentNeededHelper)
{
- $this->security = $security;
}
public function configureOptions(OptionsResolver $resolver): void
@@ -81,7 +79,7 @@ class BaseEntityAdminForm extends AbstractType
'parent',
StructuralEntityType::class,
[
- 'class' => get_class($entity),
+ 'class' => $entity::class,
'required' => false,
'label' => 'parent.label',
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
@@ -115,6 +113,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
@@ -141,7 +152,7 @@ class BaseEntityAdminForm extends AbstractType
$builder->add('log_comment', TextType::class, [
'label' => 'edit.log_comment',
'mapped' => false,
- 'required' => false,
+ 'required' => $this->eventCommentNeededHelper->isCommentNeeded($is_new ? 'datastructure_create': 'datastructure_edit'),
'empty_data' => null,
]);
diff --git a/src/Form/AdminPages/CategoryAdminForm.php b/src/Form/AdminPages/CategoryAdminForm.php
index 10a56646..44c1dede 100644
--- a/src/Form/AdminPages/CategoryAdminForm.php
+++ b/src/Form/AdminPages/CategoryAdminForm.php
@@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Form\AdminPages;
use App\Entity\Base\AbstractNamedDBElement;
+use App\Form\Part\EDA\EDACategoryInfoType;
use App\Form\Type\RichTextEditorType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
@@ -104,5 +105,11 @@ class CategoryAdminForm extends BaseEntityAdminForm
],
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
]);
+
+ //EDA info
+ $builder->add('eda_info', EDACategoryInfoType::class, [
+ 'label' => false,
+ 'required' => false,
+ ]);
}
}
diff --git a/src/Form/AdminPages/CurrencyAdminForm.php b/src/Form/AdminPages/CurrencyAdminForm.php
index 19123465..0fab055d 100644
--- a/src/Form/AdminPages/CurrencyAdminForm.php
+++ b/src/Form/AdminPages/CurrencyAdminForm.php
@@ -22,21 +22,19 @@ declare(strict_types=1);
namespace App\Form\AdminPages;
+use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Base\AbstractNamedDBElement;
use App\Form\Type\BigDecimalMoneyType;
+use App\Services\LogSystem\EventCommentNeededHelper;
use Symfony\Component\Form\Extension\Core\Type\CurrencyType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
-use Symfony\Component\Security\Core\Security;
class CurrencyAdminForm extends BaseEntityAdminForm
{
- private string $default_currency;
-
- public function __construct(Security $security, string $default_currency)
+ public function __construct(Security $security, EventCommentNeededHelper $eventCommentNeededHelper, private readonly string $base_currency)
{
- parent::__construct($security);
- $this->default_currency = $default_currency;
+ parent::__construct($security, $eventCommentNeededHelper);
}
protected function additionalFormElements(FormBuilderInterface $builder, array $options, AbstractNamedDBElement $entity): void
@@ -44,7 +42,7 @@ class CurrencyAdminForm extends BaseEntityAdminForm
$is_new = null === $entity->getID();
$builder->add('iso_code', CurrencyType::class, [
- 'required' => false,
+ 'required' => true,
'label' => 'currency.edit.iso_code',
'preferred_choices' => ['EUR', 'USD', 'GBP', 'JPY', 'CNY'],
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
@@ -53,7 +51,7 @@ class CurrencyAdminForm extends BaseEntityAdminForm
$builder->add('exchange_rate', BigDecimalMoneyType::class, [
'required' => false,
'label' => 'currency.edit.exchange_rate',
- 'currency' => $this->default_currency,
+ 'currency' => $this->base_currency,
'scale' => 6,
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
]);
diff --git a/src/Form/AdminPages/FootprintAdminForm.php b/src/Form/AdminPages/FootprintAdminForm.php
index a01e91bc..f96564d0 100644
--- a/src/Form/AdminPages/FootprintAdminForm.php
+++ b/src/Form/AdminPages/FootprintAdminForm.php
@@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Form\AdminPages;
use App\Entity\Base\AbstractNamedDBElement;
+use App\Form\Part\EDA\EDAFootprintInfoType;
use App\Form\Type\MasterPictureAttachmentType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -37,5 +38,11 @@ class FootprintAdminForm extends BaseEntityAdminForm
'filter' => '3d_model',
'entity' => $entity,
]);
+
+ //EDA info
+ $builder->add('eda_info', EDAFootprintInfoType::class, [
+ 'label' => false,
+ 'required' => false,
+ ]);
}
}
diff --git a/src/Form/AdminPages/ImportType.php b/src/Form/AdminPages/ImportType.php
index 03e8800b..3e87812c 100644
--- a/src/Form/AdminPages/ImportType.php
+++ b/src/Form/AdminPages/ImportType.php
@@ -22,7 +22,10 @@ declare(strict_types=1);
namespace App\Form\AdminPages;
+use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Base\AbstractStructuralDBElement;
+use App\Entity\Parts\Category;
+use App\Entity\Parts\Part;
use App\Form\Type\StructuralEntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@@ -31,15 +34,11 @@ use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
-use Symfony\Component\Security\Core\Security;
class ImportType extends AbstractType
{
- protected Security $security;
-
- public function __construct(Security $security)
+ public function __construct(protected Security $security)
{
- $this->security = $security;
}
public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -48,13 +47,14 @@ class ImportType extends AbstractType
//Disable import if user is not allowed to create elements.
$entity = new $data['entity_class']();
- $perm_name = 'create';
+ $perm_name = 'import';
$disabled = !$this->security->isGranted($perm_name, $entity);
$builder
->add('format', ChoiceType::class, [
'choices' => [
+ 'parts.import.format.auto' => 'auto',
'JSON' => 'json',
'XML' => 'xml',
'CSV' => 'csv',
@@ -63,7 +63,7 @@ class ImportType extends AbstractType
'label' => 'export.format',
'disabled' => $disabled,
])
- ->add('csv_separator', TextType::class, [
+ ->add('csv_delimiter', TextType::class, [
'data' => ';',
'label' => 'import.csv_separator',
'disabled' => $disabled,
@@ -78,6 +78,51 @@ class ImportType extends AbstractType
]);
}
+ if ($entity instanceof Part) {
+ $builder->add('part_category', StructuralEntityType::class, [
+ 'class' => Category::class,
+ 'required' => false,
+ 'label' => 'parts.import.part_category.label',
+ 'help' => 'parts.import.part_category.help',
+ 'disabled' => $disabled,
+ 'disable_not_selectable' => true,
+ 'allow_add' => true
+ ]);
+ $builder->add('part_needs_review', CheckboxType::class, [
+ 'data' => false,
+ 'required' => false,
+ 'label' => 'parts.import.part_needs_review.label',
+ 'help' => 'parts.import.part_needs_review.help',
+ 'disabled' => $disabled,
+ ]);
+ }
+
+ if ($entity instanceof AbstractStructuralDBElement) {
+ $builder->add('preserve_children', CheckboxType::class, [
+ 'data' => true,
+ 'required' => false,
+ 'label' => 'import.preserve_children',
+ 'disabled' => $disabled,
+ ]);
+ }
+
+ if ($entity instanceof Part) {
+ $builder->add('create_unknown_datastructures', CheckboxType::class, [
+ 'data' => true,
+ 'required' => false,
+ 'label' => 'import.create_unknown_datastructures',
+ 'help' => 'import.create_unknown_datastructures.help',
+ 'disabled' => $disabled,
+ ]);
+
+ $builder->add('path_delimiter', TextType::class, [
+ 'data' => '->',
+ 'label' => 'import.path_delimiter',
+ 'help' => 'import.path_delimiter.help',
+ 'disabled' => $disabled,
+ ]);
+ }
+
$builder->add('file', FileType::class, [
'label' => 'import.file',
'attr' => [
@@ -86,21 +131,15 @@ class ImportType extends AbstractType
'data-show-upload' => 'false',
],
'disabled' => $disabled,
- ])
+ ]);
- ->add('preserve_children', CheckboxType::class, [
- 'data' => true,
- 'required' => false,
- 'label' => 'import.preserve_children',
- 'disabled' => $disabled,
- ])
- ->add('abort_on_validation_error', CheckboxType::class, [
- 'data' => true,
- 'required' => false,
- 'label' => 'import.abort_on_validation',
- 'help' => 'import.abort_on_validation.help',
- 'disabled' => $disabled,
- ])
+ $builder->add('abort_on_validation_error', CheckboxType::class, [
+ 'data' => true,
+ 'required' => false,
+ 'label' => 'import.abort_on_validation',
+ 'help' => 'import.abort_on_validation.help',
+ 'disabled' => $disabled,
+ ])
//Buttons
->add('import', SubmitType::class, [
diff --git a/src/Form/AdminPages/MassCreationForm.php b/src/Form/AdminPages/MassCreationForm.php
index 27ee8287..4948cdd5 100644
--- a/src/Form/AdminPages/MassCreationForm.php
+++ b/src/Form/AdminPages/MassCreationForm.php
@@ -22,21 +22,18 @@ declare(strict_types=1);
namespace App\Form\AdminPages;
+use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Form\Type\StructuralEntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
-use Symfony\Component\Security\Core\Security;
class MassCreationForm extends AbstractType
{
- protected Security $security;
-
- public function __construct(Security $security)
+ public function __construct(protected Security $security)
{
- $this->security = $security;
}
public function buildForm(FormBuilderInterface $builder, array $options): void
diff --git a/src/Form/AdminPages/ProjectAdminForm.php b/src/Form/AdminPages/ProjectAdminForm.php
index 3547d094..2d4683c9 100644
--- a/src/Form/AdminPages/ProjectAdminForm.php
+++ b/src/Form/AdminPages/ProjectAdminForm.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\AdminPages;
use App\Entity\Base\AbstractNamedDBElement;
use App\Form\ProjectSystem\ProjectBOMEntryCollectionType;
-use App\Form\ProjectSystem\ProjectBOMEntryType;
use App\Form\Type\RichTextEditorType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
-use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
class ProjectAdminForm extends BaseEntityAdminForm
@@ -61,4 +61,4 @@ class ProjectAdminForm extends BaseEntityAdminForm
],
]);
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/AdminPages/StorelocationAdminForm.php b/src/Form/AdminPages/StorelocationAdminForm.php
index 8a85e4ec..c5c76b16 100644
--- a/src/Form/AdminPages/StorelocationAdminForm.php
+++ b/src/Form/AdminPages/StorelocationAdminForm.php
@@ -25,6 +25,7 @@ namespace App\Form\AdminPages;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Parts\MeasurementUnit;
use App\Form\Type\StructuralEntityType;
+use App\Form\Type\UserSelectType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -63,5 +64,16 @@ class StorelocationAdminForm extends BaseEntityAdminForm
'disable_not_selectable' => true,
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
]);
+
+ $builder->add('owner', UserSelectType::class, [
+ 'required' => false,
+ 'label' => 'storelocation.owner.label',
+ 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
+ ]);
+ $builder->add('part_owner_must_match', CheckboxType::class, [
+ 'required' => false,
+ 'label' => 'storelocation.part_owner_must_match.label',
+ 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
+ ]);
}
}
diff --git a/src/Form/AdminPages/SupplierForm.php b/src/Form/AdminPages/SupplierForm.php
index 95cecfd3..34b3b27a 100644
--- a/src/Form/AdminPages/SupplierForm.php
+++ b/src/Form/AdminPages/SupplierForm.php
@@ -22,21 +22,19 @@ declare(strict_types=1);
namespace App\Form\AdminPages;
+use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\PriceInformations\Currency;
use App\Form\Type\BigDecimalMoneyType;
use App\Form\Type\StructuralEntityType;
+use App\Services\LogSystem\EventCommentNeededHelper;
use Symfony\Component\Form\FormBuilderInterface;
-use Symfony\Component\Security\Core\Security;
class SupplierForm extends CompanyForm
{
- protected string $default_currency;
-
- public function __construct(Security $security, string $default_currency)
+ public function __construct(Security $security, EventCommentNeededHelper $eventCommentNeededHelper, protected string $base_currency)
{
- parent::__construct($security);
- $this->default_currency = $default_currency;
+ parent::__construct($security, $eventCommentNeededHelper);
}
protected function additionalFormElements(FormBuilderInterface $builder, array $options, AbstractNamedDBElement $entity): void
@@ -55,7 +53,7 @@ class SupplierForm extends CompanyForm
$builder->add('shipping_costs', BigDecimalMoneyType::class, [
'required' => false,
- 'currency' => $this->default_currency,
+ 'currency' => $this->base_currency,
'scale' => 3,
'label' => 'supplier.shipping_costs.label',
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
diff --git a/src/Form/AttachmentFormType.php b/src/Form/AttachmentFormType.php
index ad1c53a9..957d692b 100644
--- a/src/Form/AttachmentFormType.php
+++ b/src/Form/AttachmentFormType.php
@@ -22,12 +22,12 @@ declare(strict_types=1);
namespace App\Form;
+use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
use App\Form\Type\StructuralEntityType;
use App\Services\Attachments\AttachmentManager;
use App\Services\Attachments\AttachmentSubmitHandler;
-use App\Validator\Constraints\AllowedFileExtension;
use App\Validator\Constraints\UrlOrBuiltin;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@@ -37,40 +37,37 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
+use Symfony\Component\Form\FormInterface;
+use Symfony\Component\Form\FormView;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
-use Symfony\Component\Security\Core\Security;
use Symfony\Component\Validator\Constraints\File;
use Symfony\Component\Validator\Constraints\Url;
use Symfony\Contracts\Translation\TranslatorInterface;
class AttachmentFormType extends AbstractType
{
- protected AttachmentManager $attachment_helper;
- protected UrlGeneratorInterface $urlGenerator;
- protected bool $allow_attachments_download;
- protected Security $security;
- protected AttachmentSubmitHandler $submitHandler;
- protected TranslatorInterface $translator;
-
- public function __construct(AttachmentManager $attachmentHelper,
- UrlGeneratorInterface $urlGenerator, Security $security,
- bool $allow_attachments_downloads, AttachmentSubmitHandler $submitHandler, TranslatorInterface $translator)
- {
- $this->attachment_helper = $attachmentHelper;
- $this->urlGenerator = $urlGenerator;
- $this->allow_attachments_download = $allow_attachments_downloads;
- $this->security = $security;
- $this->submitHandler = $submitHandler;
- $this->translator = $translator;
+ public function __construct(
+ protected AttachmentManager $attachment_helper,
+ protected UrlGeneratorInterface $urlGenerator,
+ protected Security $security,
+ protected AttachmentSubmitHandler $submitHandler,
+ protected TranslatorInterface $translator,
+ protected bool $allow_attachments_download,
+ protected bool $download_by_default,
+ protected string $max_file_size
+ ) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
- $builder->add('name', TextType::class, [
- 'label' => 'attachment.edit.name',
- ])
+ $builder
+ ->add('name', TextType::class, [
+ 'label' => 'attachment.edit.name',
+ 'required' => false,
+ 'empty_data' => '',
+ ])
->add('attachment_type', StructuralEntityType::class, [
'label' => 'attachment.edit.attachment_type',
'class' => AttachmentType::class,
@@ -96,7 +93,8 @@ class AttachmentFormType extends AbstractType
'required' => false,
'attr' => [
'data-controller' => 'elements--attachment-autocomplete',
- 'data-autocomplete' => $this->urlGenerator->generate('typeahead_builtInRessources', ['query' => '__QUERY__']),
+ 'data-autocomplete' => $this->urlGenerator->generate('typeahead_builtInRessources',
+ ['query' => '__QUERY__']),
//Disable browser autocomplete
'autocomplete' => 'off',
],
@@ -130,6 +128,7 @@ class AttachmentFormType extends AbstractType
],
]);
+
$builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void {
$form = $event->getForm();
$attachment = $form->getData();
@@ -137,13 +136,33 @@ class AttachmentFormType extends AbstractType
$file_form = $form->get('file');
$file = $file_form->getData();
- if ($attachment instanceof Attachment && $file instanceof UploadedFile && $attachment->getAttachmentType(
- ) && !$this->submitHandler->isValidFileExtension($attachment->getAttachmentType(), $file)) {
- $event->getForm()->get('file')->addError(
- new FormError($this->translator->trans('validator.file_ext_not_allowed'))
- );
+ if (!$attachment instanceof Attachment) {
+ return;
}
- });
+
+ if (!$file instanceof UploadedFile) {
+ //When no file was uploaded, but a URL was entered, try to determine the attachment name from the URL
+ if ((trim($attachment->getName()) === '') && ($attachment->getURL() !== null && $attachment->getURL() !== '')) {
+ $name = basename(parse_url($attachment->getURL(), PHP_URL_PATH));
+ $attachment->setName($name);
+ }
+
+ return;
+ }
+
+ //Ensure that the file extension is allowed for the selected attachment type
+ if ($attachment->getAttachmentType()
+ && !$this->submitHandler->isValidFileExtension($attachment->getAttachmentType(), $file)) {
+ $event->getForm()->get('file')->addError(
+ new FormError($this->translator->trans('validator.file_ext_not_allowed'))
+ );
+ }
+
+ //If the name is empty, use the original file name as attachment name
+ if ($attachment->getName() === '') {
+ $attachment->setName($file->getClientOriginalName());
+ }
+ }, 100000);
//Check the secure file checkbox, if file is in securefile location
$builder->get('secureFile')->addEventListener(
@@ -155,17 +174,46 @@ class AttachmentFormType extends AbstractType
}
}
);
+
+ //If the attachment should be downloaded by default (and is download allowed at all), register a listener,
+ // which sets the downloadURL checkbox to true for new attachments
+ if ($this->download_by_default && $this->allow_attachments_download) {
+ $builder->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event): void {
+ $form = $event->getForm();
+ $attachment = $form->getData();
+
+ if (!$attachment instanceof Attachment && $attachment !== null) {
+ return;
+ }
+
+ //If the attachment was not created yet, set the downloadURL checkbox to true
+ if ($attachment === null || $attachment->getId() === null) {
+ $checkbox = $form->get('downloadURL');
+ //Ensure that the checkbox is not disabled
+ if ($checkbox->isDisabled()) {
+ return;
+ }
+ //Set the checkbox
+ $checkbox->setData(true);
+ }
+ });
+ }
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Attachment::class,
- 'max_file_size' => '16M',
+ 'max_file_size' => $this->max_file_size,
'allow_builtins' => true,
]);
}
+ public function finishView(FormView $view, FormInterface $form, array $options): void
+ {
+ $view->vars['max_upload_size'] = $this->submitHandler->getMaximumAllowedUploadSize();
+ }
+
public function getBlockPrefix(): string
{
return 'attachment';
diff --git a/src/Form/CollectionTypeExtension.php b/src/Form/CollectionTypeExtension.php
index 98807f01..4fa93852 100644
--- a/src/Form/CollectionTypeExtension.php
+++ b/src/Form/CollectionTypeExtension.php
@@ -44,14 +44,18 @@ namespace App\Form;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use ReflectionClass;
-use ReflectionException;
use Symfony\Component\Form\AbstractTypeExtension;
+use Symfony\Component\Form\Event\PreSubmitEvent;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilder;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormConfigBuilder;
+use Symfony\Component\Form\FormConfigInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
+use Symfony\Component\Form\FormInterface;
+use Symfony\Component\Form\FormView;
+use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
@@ -59,15 +63,12 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
* Perform a reindexing on CollectionType elements, by assigning the database id as index.
* This prevents issues when the collection that is edited uses a OrderBy annotation and therefore the direction of the
* elements can change during requests.
- * Must me enabled by setting reindex_enable to true in Type options.
+ * Must be enabled by setting reindex_enable to true in Type options.
*/
class CollectionTypeExtension extends AbstractTypeExtension
{
- protected PropertyAccessorInterface $propertyAccess;
-
- public function __construct(PropertyAccessorInterface $propertyAccess)
+ public function __construct(protected PropertyAccessorInterface $propertyAccess)
{
- $this->propertyAccess = $propertyAccess;
}
public static function getExtendedTypes(): iterable
@@ -87,11 +88,23 @@ class CollectionTypeExtension extends AbstractTypeExtension
'reindex_path' => 'id',
]);
+ //Set a unique prototype name, so that we can use nested collections
+ $resolver->setDefaults([
+ 'prototype_name' => fn(Options $options): string => '__name_'.uniqid("", false) . '__',
+ ]);
+
$resolver->setAllowedTypes('reindex_enable', 'bool');
$resolver->setAllowedTypes('reindex_prefix', 'string');
$resolver->setAllowedTypes('reindex_path', 'string');
}
+ public function finishView(FormView $view, FormInterface $form, array $options): void
+ {
+ parent::finishView($view, $form, $options);
+ //Add prototype name to view, so that we can pass it to the stimulus controller
+ $view->vars['prototype_name'] = $options['prototype_name'];
+ }
+
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options): void {
@@ -118,22 +131,52 @@ class CollectionTypeExtension extends AbstractTypeExtension
}
}
}, 100); //We need to have a higher priority then the PRE_SET_DATA listener on CollectionType
+
+ // This event listener fixes the error mapping for newly created elements of collection types
+ // Without this method, the errors for newly created elements are shown on the parent element, as forms
+ // can not map it to the correct element.
+ $builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event) {
+ $data = $event->getData();
+ $form = $event->getForm();
+ $config = $form->getConfig();
+
+ if (!is_array($data) && !$data instanceof Collection) {
+ return;
+ }
+
+ if ($data instanceof Collection) {
+ $data = $data->toArray();
+ }
+
+ //The validator uses the number of the element as index, so we have to map the errors to the correct index
+ $error_mapping = [];
+ $n = 0;
+ foreach (array_keys($data) as $key) {
+ $error_mapping['['.$n.']'] = $key;
+ $n++;
+ }
+ $this->setOption($config, 'error_mapping', $error_mapping);
+ });
}
/**
* Set the option of the form.
- * This a bit hacky cause we access private properties....
- *
+ * This a bit hacky because we access private properties....
+ * @param FormConfigInterface $builder The form on which the option should be set
+ * @param string $option The option which should be changed
+ * @param mixed $value The new value
*/
- public function setOption(FormBuilder $builder, string $option, $value): void
+ public function setOption(FormConfigInterface $builder, string $option, mixed $value): void
{
- //We have to use FormConfigBuilder::class here, because options is private and not available in sub classes
+ if (!$builder instanceof FormConfigBuilder) {
+ throw new \RuntimeException('This method only works with FormConfigBuilder instances.');
+ }
+
+ //We have to use FormConfigBuilder::class here, because options is private and not available in subclasses
$reflection = new ReflectionClass(FormConfigBuilder::class);
$property = $reflection->getProperty('options');
- $property->setAccessible(true);
$tmp = $property->getValue($builder);
$tmp[$option] = $value;
$property->setValue($builder, $tmp);
- $property->setAccessible(false);
}
}
diff --git a/src/Form/Filters/AttachmentFilterType.php b/src/Form/Filters/AttachmentFilterType.php
index 57967be7..ff80bd38 100644
--- a/src/Form/Filters/AttachmentFilterType.php
+++ b/src/Form/Filters/AttachmentFilterType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Filters;
use App\DataTables\Filters\AttachmentFilter;
@@ -30,19 +32,16 @@ use App\Entity\Attachments\FootprintAttachment;
use App\Entity\Attachments\GroupAttachment;
use App\Entity\Attachments\LabelAttachment;
use App\Entity\Attachments\PartAttachment;
-use App\Entity\Attachments\StorelocationAttachment;
+use App\Entity\Attachments\StorageLocationAttachment;
use App\Entity\Attachments\SupplierAttachment;
use App\Entity\Attachments\UserAttachment;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
-use App\Entity\Parts\Supplier;
-use App\Form\AdminPages\FootprintAdminForm;
use App\Form\Filters\Constraints\BooleanConstraintType;
use App\Form\Filters\Constraints\DateTimeConstraintType;
use App\Form\Filters\Constraints\InstanceOfConstraintType;
use App\Form\Filters\Constraints\NumberConstraintType;
use App\Form\Filters\Constraints\StructuralEntityConstraintType;
-use App\Form\Filters\Constraints\UserEntityConstraintType;
use App\Form\Filters\Constraints\TextConstraintType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ResetType;
@@ -61,7 +60,7 @@ class AttachmentFilterType extends AbstractType
]);
}
- public function buildForm(FormBuilderInterface $builder, array $options)
+ public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('dbId', NumberConstraintType::class, [
'label' => 'part.filter.dbId',
@@ -86,7 +85,7 @@ class AttachmentFilterType extends AbstractType
'label_profile.label' => LabelAttachment::class,
'manufacturer.label' => Manufacturer::class,
'measurement_unit.label' => MeasurementUnit::class,
- 'storelocation.label' => StorelocationAttachment::class,
+ 'storelocation.label' => StorageLocationAttachment::class,
'supplier.label' => SupplierAttachment::class,
'user.label' => UserAttachment::class,
]
@@ -101,6 +100,15 @@ class AttachmentFilterType extends AbstractType
'label' => 'attachment.edit.show_in_table'
]);
+ $builder->add('originalFileName', TextConstraintType::class, [
+ 'label' => 'attachment.file_name'
+ ]);
+
+ $builder->add('externalLink', TextConstraintType::class, [
+ 'label' => 'attachment.table.external_link'
+ ]);
+
+
$builder->add('lastModified', DateTimeConstraintType::class, [
'label' => 'lastModified'
]);
@@ -117,4 +125,4 @@ class AttachmentFilterType extends AbstractType
'label' => 'filter.discard',
]);
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/Filters/Constraints/BooleanConstraintType.php b/src/Form/Filters/Constraints/BooleanConstraintType.php
index e04e88d3..6669b5a7 100644
--- a/src/Form/Filters/Constraints/BooleanConstraintType.php
+++ b/src/Form/Filters/Constraints/BooleanConstraintType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\BooleanConstraint;
use App\Form\Type\TriStateCheckboxType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\Form\FormInterface;
+use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
class BooleanConstraintType extends AbstractType
@@ -43,4 +47,10 @@ class BooleanConstraintType extends AbstractType
'required' => false,
]);
}
-}
\ No newline at end of file
+
+ public function finishView(FormView $view, FormInterface $form, array $options): void
+ {
+ //Remove the label from the compound form, as the checkbox already has a label
+ $view->vars['label'] = false;
+ }
+}
diff --git a/src/Form/Filters/Constraints/ChoiceConstraintType.php b/src/Form/Filters/Constraints/ChoiceConstraintType.php
index 16014c7f..70d37b08 100644
--- a/src/Form/Filters/Constraints/ChoiceConstraintType.php
+++ b/src/Form/Filters/Constraints/ChoiceConstraintType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\ChoiceConstraint;
@@ -63,4 +65,4 @@ class ChoiceConstraintType extends AbstractType
]);
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/Filters/Constraints/DateTimeConstraintType.php b/src/Form/Filters/Constraints/DateTimeConstraintType.php
index f476f0ed..ffd3aafd 100644
--- a/src/Form/Filters/Constraints/DateTimeConstraintType.php
+++ b/src/Form/Filters/Constraints/DateTimeConstraintType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\DateTimeConstraint;
-use App\DataTables\Filters\Constraints\NumberConstraint;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
-use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
@@ -86,10 +86,10 @@ class DateTimeConstraintType extends AbstractType
]);
}
- public function buildView(FormView $view, FormInterface $form, array $options)
+ public function buildView(FormView $view, FormInterface $form, array $options): void
{
parent::buildView($view, $form, $options);
$view->vars['text_suffix'] = $options['text_suffix'];
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/Filters/Constraints/EnumConstraintType.php b/src/Form/Filters/Constraints/EnumConstraintType.php
new file mode 100644
index 00000000..59259169
--- /dev/null
+++ b/src/Form/Filters/Constraints/EnumConstraintType.php
@@ -0,0 +1,73 @@
+.
+ */
+namespace App\Form\Filters\Constraints;
+
+use App\DataTables\Filters\Constraints\ChoiceConstraint;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
+use Symfony\Component\Form\Extension\Core\Type\EnumType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class EnumConstraintType extends AbstractType
+{
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setRequired('enum_class');
+ $resolver->setAllowedTypes('enum_class', 'string');
+
+ $resolver->setRequired('choice_label');
+ $resolver->setAllowedTypes('choice_label', ['string', 'callable']);
+
+ $resolver->setDefaults([
+ 'compound' => true,
+ 'data_class' => ChoiceConstraint::class,
+ ]);
+
+ }
+
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $choices = [
+ '' => '',
+ 'filter.choice_constraint.operator.ANY' => 'ANY',
+ 'filter.choice_constraint.operator.NONE' => 'NONE',
+ ];
+
+ $builder->add('operator', ChoiceType::class, [
+ 'choices' => $choices,
+ 'required' => false,
+ ]);
+
+ $builder->add('value', EnumType::class, [
+ 'class' => $options['enum_class'],
+ 'choice_label' => $options['choice_label'],
+ 'required' => false,
+ 'multiple' => true,
+ 'attr' => [
+ 'data-controller' => 'elements--select-multiple',
+ ]
+ ]);
+ }
+
+}
diff --git a/src/Form/Filters/Constraints/InstanceOfConstraintType.php b/src/Form/Filters/Constraints/InstanceOfConstraintType.php
index 4a53e0f5..02de15e5 100644
--- a/src/Form/Filters/Constraints/InstanceOfConstraintType.php
+++ b/src/Form/Filters/Constraints/InstanceOfConstraintType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\InstanceOfConstraint;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\AbstractType;
-use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
-use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
class InstanceOfConstraintType extends AbstractType
{
- protected EntityManagerInterface $em;
-
- public function __construct(EntityManagerInterface $entityManager)
+ public function __construct(protected EntityManagerInterface $em)
{
- $this->em = $entityManager;
}
- public function configureOptions(OptionsResolver $resolver)
+ public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('data_class', InstanceOfConstraint::class);
}
- public function getParent()
+ public function getParent(): string
{
return ChoiceConstraintType::class;
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/Filters/Constraints/NumberConstraintType.php b/src/Form/Filters/Constraints/NumberConstraintType.php
index cbfd6897..ad792fa0 100644
--- a/src/Form/Filters/Constraints/NumberConstraintType.php
+++ b/src/Form/Filters/Constraints/NumberConstraintType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\NumberConstraint;
@@ -47,7 +49,7 @@ class NumberConstraintType extends AbstractType
$resolver->setDefaults([
'compound' => true,
'data_class' => NumberConstraint::class,
- 'text_suffix' => '', // An suffix which is attached as text-append to the input group. This can for example be used for units
+ 'text_suffix' => '', // A suffix which is attached as text-append to the input group. This can for example be used for units
'min' => null,
'max' => null,
@@ -91,10 +93,8 @@ class NumberConstraintType extends AbstractType
]);
}
- public function buildView(FormView $view, FormInterface $form, array $options)
+ public function buildView(FormView $view, FormInterface $form, array $options): void
{
- parent::buildView($view, $form, $options);
-
$view->vars['text_suffix'] = $options['text_suffix'];
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/Filters/Constraints/ParameterConstraintType.php b/src/Form/Filters/Constraints/ParameterConstraintType.php
index f9f2b311..3c3b396d 100644
--- a/src/Form/Filters/Constraints/ParameterConstraintType.php
+++ b/src/Form/Filters/Constraints/ParameterConstraintType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
-use Svg\Tag\Text;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SearchType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
@@ -49,10 +50,12 @@ class ParameterConstraintType extends AbstractType
$builder->add('unit', SearchType::class, [
'required' => false,
+ 'empty_data' => '',
]);
$builder->add('symbol', SearchType::class, [
- 'required' => false
+ 'required' => false,
+ 'empty_data' => '',
]);
$builder->add('value_text', TextConstraintType::class, [
@@ -69,7 +72,6 @@ class ParameterConstraintType extends AbstractType
* Ensure that the data is never null, but use an empty ParameterConstraint instead
*/
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
- $form = $event->getForm();
$data = $event->getData();
if ($data === null) {
@@ -77,4 +79,4 @@ class ParameterConstraintType extends AbstractType
}
});
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/Filters/Constraints/ParameterValueConstraintType.php b/src/Form/Filters/Constraints/ParameterValueConstraintType.php
index a99fd2a4..01fbca5c 100644
--- a/src/Form/Filters/Constraints/ParameterValueConstraintType.php
+++ b/src/Form/Filters/Constraints/ParameterValueConstraintType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Filters\Constraints;
class ParameterValueConstraintType extends NumberConstraintType
@@ -48,4 +50,4 @@ class ParameterValueConstraintType extends NumberConstraintType
{
return NumberConstraintType::class;
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/Filters/Constraints/StructuralEntityConstraintType.php b/src/Form/Filters/Constraints/StructuralEntityConstraintType.php
index c9d6f25a..5191881b 100644
--- a/src/Form/Filters/Constraints/StructuralEntityConstraintType.php
+++ b/src/Form/Filters/Constraints/StructuralEntityConstraintType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\EntityConstraint;
@@ -36,7 +38,7 @@ class StructuralEntityConstraintType extends AbstractType
$resolver->setDefaults([
'compound' => true,
'data_class' => EntityConstraint::class,
- 'text_suffix' => '', // An suffix which is attached as text-append to the input group. This can for example be used for units
+ 'text_suffix' => '', // A suffix which is attached as text-append to the input group. This can for example be used for units
]);
$resolver->setRequired('entity_class');
@@ -65,9 +67,9 @@ class StructuralEntityConstraintType extends AbstractType
]);
}
- public function buildView(FormView $view, FormInterface $form, array $options)
+ public function buildView(FormView $view, FormInterface $form, array $options): void
{
parent::buildView($view, $form, $options);
$view->vars['text_suffix'] = $options['text_suffix'];
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/Filters/Constraints/TagsConstraintType.php b/src/Form/Filters/Constraints/TagsConstraintType.php
index e6134a65..0a7661dd 100644
--- a/src/Form/Filters/Constraints/TagsConstraintType.php
+++ b/src/Form/Filters/Constraints/TagsConstraintType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\Part\TagsConstraint;
@@ -30,11 +32,8 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class TagsConstraintType extends AbstractType
{
- protected UrlGeneratorInterface $urlGenerator;
-
- public function __construct(UrlGeneratorInterface $urlGenerator)
+ public function __construct(protected UrlGeneratorInterface $urlGenerator)
{
- $this->urlGenerator = $urlGenerator;
}
public function configureOptions(OptionsResolver $resolver): void
@@ -71,4 +70,4 @@ class TagsConstraintType extends AbstractType
'required' => false,
]);
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/Filters/Constraints/TextConstraintType.php b/src/Form/Filters/Constraints/TextConstraintType.php
index b9415977..c1007cf9 100644
--- a/src/Form/Filters/Constraints/TextConstraintType.php
+++ b/src/Form/Filters/Constraints/TextConstraintType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\TextConstraint;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
-use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\SearchType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
@@ -70,10 +71,10 @@ class TextConstraintType extends AbstractType
]);
}
- public function buildView(FormView $view, FormInterface $form, array $options)
+ public function buildView(FormView $view, FormInterface $form, array $options): void
{
parent::buildView($view, $form, $options);
$view->vars['text_suffix'] = $options['text_suffix'];
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/Filters/Constraints/UserEntityConstraintType.php b/src/Form/Filters/Constraints/UserEntityConstraintType.php
index 9b28e2ce..8c82e0d8 100644
--- a/src/Form/Filters/Constraints/UserEntityConstraintType.php
+++ b/src/Form/Filters/Constraints/UserEntityConstraintType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\EntityConstraint;
-use App\Entity\UserSystem\User;
-use App\Form\Type\StructuralEntityType;
-use Symfony\Bridge\Doctrine\Form\Type\EntityType;
+use App\Form\Type\UserSelectType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
-use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
class UserEntityConstraintType extends AbstractType
@@ -39,7 +38,7 @@ class UserEntityConstraintType extends AbstractType
$resolver->setDefaults([
'compound' => true,
'data_class' => EntityConstraint::class,
- 'text_suffix' => '', // An suffix which is attached as text-append to the input group. This can for example be used for units
+ 'text_suffix' => '', //A suffix which is attached as text-append to the input group. This can for example be used for units
]);
}
@@ -51,8 +50,7 @@ class UserEntityConstraintType extends AbstractType
'filter.entity_constraint.operator.NEQ' => '!=',
];
- $builder->add('value', EntityType::class, [
- 'class' => User::class,
+ $builder->add('value', UserSelectType::class, [
'required' => false,
]);
@@ -64,9 +62,9 @@ class UserEntityConstraintType extends AbstractType
]);
}
- public function buildView(FormView $view, FormInterface $form, array $options)
+ public function buildView(FormView $view, FormInterface $form, array $options): void
{
parent::buildView($view, $form, $options);
$view->vars['text_suffix'] = $options['text_suffix'];
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/Filters/LogFilterType.php b/src/Form/Filters/LogFilterType.php
index f7b460b6..42b367b7 100644
--- a/src/Form/Filters/LogFilterType.php
+++ b/src/Form/Filters/LogFilterType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Filters;
use App\DataTables\Filters\LogFilter;
-use App\Entity\Attachments\Attachment;
-use App\Entity\Attachments\AttachmentType;
+use App\Entity\LogSystem\LogLevel;
+use App\Entity\LogSystem\LogTargetType;
use App\Entity\LogSystem\PartStockChangedLogEntry;
-use App\Entity\ProjectSystem\Project;
-use App\Entity\ProjectSystem\ProjectBOMEntry;
-use App\Entity\LabelSystem\LabelProfile;
-use App\Entity\LogSystem\AbstractLogEntry;
use App\Entity\LogSystem\CollectionElementDeleted;
use App\Entity\LogSystem\DatabaseUpdatedLogEntry;
use App\Entity\LogSystem\ElementCreatedLogEntry;
@@ -38,25 +36,10 @@ use App\Entity\LogSystem\SecurityEventLogEntry;
use App\Entity\LogSystem\UserLoginLogEntry;
use App\Entity\LogSystem\UserLogoutLogEntry;
use App\Entity\LogSystem\UserNotAllowedLogEntry;
-use App\Entity\Parameters\AbstractParameter;
-use App\Entity\Parts\Category;
-use App\Entity\Parts\Footprint;
-use App\Entity\Parts\Manufacturer;
-use App\Entity\Parts\MeasurementUnit;
-use App\Entity\Parts\Part;
-use App\Entity\Parts\PartLot;
-use App\Entity\Parts\Storelocation;
-use App\Entity\Parts\Supplier;
-use App\Entity\PriceInformations\Currency;
-use App\Entity\PriceInformations\Orderdetail;
-use App\Entity\PriceInformations\Pricedetail;
-use App\Entity\UserSystem\Group;
-use App\Entity\UserSystem\User;
-use App\Form\Filters\Constraints\ChoiceConstraintType;
use App\Form\Filters\Constraints\DateTimeConstraintType;
+use App\Form\Filters\Constraints\EnumConstraintType;
use App\Form\Filters\Constraints\InstanceOfConstraintType;
use App\Form\Filters\Constraints\NumberConstraintType;
-use App\Form\Filters\Constraints\StructuralEntityConstraintType;
use App\Form\Filters\Constraints\UserEntityConstraintType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ResetType;
@@ -66,17 +49,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class LogFilterType extends AbstractType
{
- protected const LEVEL_CHOICES = [
- 'log.level.debug' => AbstractLogEntry::LEVEL_DEBUG,
- 'log.level.info' => AbstractLogEntry::LEVEL_INFO,
- 'log.level.notice' => AbstractLogEntry::LEVEL_NOTICE,
- 'log.level.warning' => AbstractLogEntry::LEVEL_WARNING,
- 'log.level.error' => AbstractLogEntry::LEVEL_ERROR,
- 'log.level.critical' => AbstractLogEntry::LEVEL_CRITICAL,
- 'log.level.alert' => AbstractLogEntry::LEVEL_ALERT,
- 'log.level.emergency' => AbstractLogEntry::LEVEL_EMERGENCY,
- ];
-
protected const TARGET_TYPE_CHOICES = [
'log.type.collection_element_deleted' => CollectionElementDeleted::class,
'log.type.database_updated' => DatabaseUpdatedLogEntry::class,
@@ -102,7 +74,7 @@ class LogFilterType extends AbstractType
]);
}
- public function buildForm(FormBuilderInterface $builder, array $options)
+ public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('dbId', NumberConstraintType::class, [
'label' => 'part.filter.dbId',
@@ -116,9 +88,10 @@ class LogFilterType extends AbstractType
- $builder->add('level', ChoiceConstraintType::class, [
+ $builder->add('level', EnumConstraintType::class, [
'label' => 'log.level',
- 'choices' => self::LEVEL_CHOICES,
+ 'enum_class' => LogLevel::class,
+ 'choice_label' => fn(LogLevel $level): string => 'log.level.' . $level->toPSR3LevelString(),
]);
$builder->add('eventType', InstanceOfConstraintType::class, [
@@ -130,29 +103,32 @@ class LogFilterType extends AbstractType
'label' => 'log.user',
]);
- $builder->add('targetType', ChoiceConstraintType::class, [
+ $builder->add('targetType', EnumConstraintType::class, [
'label' => 'log.target_type',
- 'choices' => [
- 'user.label' => AbstractLogEntry::targetTypeClassToID(User::class),
- 'attachment.label' => AbstractLogEntry::targetTypeClassToID(Attachment::class),
- 'attachment_type.label' => AbstractLogEntry::targetTypeClassToID(AttachmentType::class),
- 'category.label' => AbstractLogEntry::targetTypeClassToID(Category::class),
- 'project.label' => AbstractLogEntry::targetTypeClassToID(Project::class),
- 'project_bom_entry.label' => AbstractLogEntry::targetTypeClassToID(ProjectBOMEntry::class),
- 'footprint.label' => AbstractLogEntry::targetTypeClassToID(Footprint::class),
- 'group.label' => AbstractLogEntry::targetTypeClassToID(Group::class),
- 'manufacturer.label' => AbstractLogEntry::targetTypeClassToID(Manufacturer::class),
- 'part.label' => AbstractLogEntry::targetTypeClassToID(Part::class),
- 'storelocation.label' => AbstractLogEntry::targetTypeClassToID(Storelocation::class),
- 'supplier.label' => AbstractLogEntry::targetTypeClassToID(Supplier::class),
- 'part_lot.label' => AbstractLogEntry::targetTypeClassToID(PartLot::class),
- 'currency.label' => AbstractLogEntry::targetTypeClassToID(Currency::class),
- 'orderdetail.label' => AbstractLogEntry::targetTypeClassToID(Orderdetail::class),
- 'pricedetail.label' => AbstractLogEntry::targetTypeClassToID(Pricedetail::class),
- 'measurement_unit.label' => AbstractLogEntry::targetTypeClassToID(MeasurementUnit::class),
- 'parameter.label' => AbstractLogEntry::targetTypeClassToID(AbstractParameter::class),
- 'label_profile.label' => AbstractLogEntry::targetTypeClassToID(LabelProfile::class),
- ]
+ 'enum_class' => LogTargetType::class,
+ 'choice_label' => fn(LogTargetType $type): string => match ($type) {
+ LogTargetType::NONE => 'log.target_type.none',
+ LogTargetType::USER => 'user.label',
+ LogTargetType::ATTACHMENT => 'attachment.label',
+ LogTargetType::ATTACHMENT_TYPE => 'attachment_type.label',
+ LogTargetType::CATEGORY => 'category.label',
+ LogTargetType::PROJECT => 'project.label',
+ LogTargetType::BOM_ENTRY => 'project_bom_entry.label',
+ LogTargetType::FOOTPRINT => 'footprint.label',
+ LogTargetType::GROUP => 'group.label',
+ LogTargetType::MANUFACTURER => 'manufacturer.label',
+ LogTargetType::PART => 'part.label',
+ LogTargetType::STORELOCATION => 'storelocation.label',
+ LogTargetType::SUPPLIER => 'supplier.label',
+ LogTargetType::PART_LOT => 'part_lot.label',
+ LogTargetType::CURRENCY => 'currency.label',
+ LogTargetType::ORDERDETAIL => 'orderdetail.label',
+ LogTargetType::PRICEDETAIL => 'pricedetail.label',
+ LogTargetType::MEASUREMENT_UNIT => 'measurement_unit.label',
+ LogTargetType::PARAMETER => 'parameter.label',
+ LogTargetType::LABEL_PROFILE => 'label_profile.label',
+ LogTargetType::PART_ASSOCIATION => 'part_association.label',
+ },
]);
$builder->add('targetId', NumberConstraintType::class, [
@@ -169,4 +145,4 @@ class LogFilterType extends AbstractType
'label' => 'filter.discard',
]);
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php
index 347948a2..dfe449d1 100644
--- a/src/Form/Filters/PartFilterType.php
+++ b/src/Form/Filters/PartFilterType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Filters;
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
@@ -27,7 +29,9 @@ use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
-use App\Entity\Parts\Storelocation;
+use App\Entity\Parts\StorageLocation;
+use App\Entity\Parts\Supplier;
+use App\Entity\ProjectSystem\Project;
use App\Form\Filters\Constraints\BooleanConstraintType;
use App\Form\Filters\Constraints\ChoiceConstraintType;
use App\Form\Filters\Constraints\DateTimeConstraintType;
@@ -36,19 +40,22 @@ use App\Form\Filters\Constraints\ParameterConstraintType;
use App\Form\Filters\Constraints\StructuralEntityConstraintType;
use App\Form\Filters\Constraints\TagsConstraintType;
use App\Form\Filters\Constraints\TextConstraintType;
-use Svg\Tag\Text;
+use App\Form\Filters\Constraints\UserEntityConstraintType;
+use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
-use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\ResetType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
-use Symfony\Component\Form\SubmitButton;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PartFilterType extends AbstractType
{
+ public function __construct(private readonly Security $security)
+ {
+ }
+
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
@@ -58,7 +65,7 @@ class PartFilterType extends AbstractType
]);
}
- public function buildForm(FormBuilderInterface $builder, array $options)
+ public function buildForm(FormBuilderInterface $builder, array $options): void
{
/*
* Common tab
@@ -169,13 +176,13 @@ class PartFilterType extends AbstractType
$builder->add('supplier', StructuralEntityConstraintType::class, [
'label' => 'supplier.label',
- 'entity_class' => Manufacturer::class
+ 'entity_class' => Supplier::class
]);
$builder->add('orderdetailsCount', NumberConstraintType::class, [
'label' => 'part.filter.orderdetails_count',
- 'step' => 1,
- 'min' => 0,
+ 'step' => 1,
+ 'min' => 0,
]);
$builder->add('obsolete', BooleanConstraintType::class, [
@@ -187,7 +194,7 @@ class PartFilterType extends AbstractType
*/
$builder->add('storelocation', StructuralEntityConstraintType::class, [
'label' => 'storelocation.label',
- 'entity_class' => Storelocation::class
+ 'entity_class' => StorageLocation::class
]);
$builder->add('minAmount', NumberConstraintType::class, [
@@ -206,6 +213,10 @@ class PartFilterType extends AbstractType
'min' => 0,
]);
+ $builder->add('lessThanDesired', BooleanConstraintType::class, [
+ 'label' => 'part.filter.lessThanDesired'
+ ]);
+
$builder->add('lotNeedsRefill', BooleanConstraintType::class, [
'label' => 'part.filter.lotNeedsRefill'
]);
@@ -223,6 +234,10 @@ class PartFilterType extends AbstractType
'label' => 'part.filter.lotDescription',
]);
+ $builder->add('lotOwner', UserEntityConstraintType::class, [
+ 'label' => 'part.filter.lotOwner',
+ ]);
+
/**
* Attachments count
*/
@@ -259,6 +274,31 @@ class PartFilterType extends AbstractType
'min' => 0,
]);
+ /**************************************************************************
+ * Project tab
+ **************************************************************************/
+ if ($this->security->isGranted('read', Project::class)) {
+ $builder
+ ->add('project', StructuralEntityConstraintType::class, [
+ 'label' => 'project.label',
+ 'entity_class' => Project::class
+ ])
+ ->add('bomQuantity', NumberConstraintType::class, [
+ 'label' => 'project.bom.quantity',
+ 'min' => 0,
+ 'step' => "any",
+ ])
+ ->add('bomName', TextConstraintType::class, [
+ 'label' => 'project.bom.name',
+ ])
+ ->add('bomComment', TextConstraintType::class, [
+ 'label' => 'project.bom.comment',
+ ])
+ ;
+
+ }
+
+
$builder->add('submit', SubmitType::class, [
'label' => 'filter.submit',
]);
@@ -268,4 +308,4 @@ class PartFilterType extends AbstractType
]);
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/InfoProviderSystem/PartSearchType.php b/src/Form/InfoProviderSystem/PartSearchType.php
new file mode 100644
index 00000000..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..a9373390
--- /dev/null
+++ b/src/Form/InfoProviderSystem/ProviderSelectType.php
@@ -0,0 +1,56 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Form\InfoProviderSystem;
+
+use App\Services\InfoProviderSystem\ProviderRegistry;
+use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
+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): void
+ {
+ $resolver->setDefaults([
+ 'choices' => $this->providerRegistry->getActiveProviders(),
+ 'choice_label' => ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => $choice?->getProviderInfo()['name']),
+ 'choice_value' => ChoiceList::value($this, static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey()),
+
+ 'multiple' => true,
+ ]);
+ }
+
+}
\ No newline at end of file
diff --git a/src/Form/LabelOptionsType.php b/src/Form/LabelOptionsType.php
index 5af69ce6..ad458374 100644
--- a/src/Form/LabelOptionsType.php
+++ b/src/Form/LabelOptionsType.php
@@ -41,23 +41,23 @@ declare(strict_types=1);
namespace App\Form;
+use App\Entity\LabelSystem\BarcodeType;
+use App\Entity\LabelSystem\LabelProcessMode;
+use App\Entity\LabelSystem\LabelSupportedElement;
+use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\LabelSystem\LabelOptions;
use App\Form\Type\RichTextEditorType;
use Symfony\Component\Form\AbstractType;
-use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
+use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
-use Symfony\Component\Security\Core\Security;
class LabelOptionsType extends AbstractType
{
- private Security $security;
-
- public function __construct(Security $security)
+ public function __construct(private readonly Security $security)
{
- $this->security = $security;
}
public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -81,31 +81,33 @@ class LabelOptionsType extends AbstractType
],
]);
- $builder->add('supported_element', ChoiceType::class, [
+ $builder->add('supported_element', EnumType::class, [
'label' => 'label_options.supported_elements.label',
- 'choices' => [
- 'part.label' => 'part',
- 'part_lot.label' => 'part_lot',
- 'storelocation.label' => 'storelocation',
- ],
+ 'class' => LabelSupportedElement::class,
+ 'choice_label' => fn(LabelSupportedElement $choice) => match($choice) {
+ LabelSupportedElement::PART => 'part.label',
+ LabelSupportedElement::PART_LOT => 'part_lot.label',
+ LabelSupportedElement::STORELOCATION => 'storelocation.label',
+ },
]);
- $builder->add('barcode_type', ChoiceType::class, [
+ $builder->add('barcode_type', EnumType::class, [
'label' => 'label_options.barcode_type.label',
'empty_data' => 'none',
- 'choices' => [
- 'label_options.barcode_type.none' => 'none',
- 'label_options.barcode_type.qr' => 'qr',
- 'label_options.barcode_type.code128' => 'code128',
- 'label_options.barcode_type.code39' => 'code39',
- 'label_options.barcode_type.code93' => 'code93',
- 'label_options.barcode_type.datamatrix' => 'datamatrix',
- ],
- 'group_by' => static function ($choice, $key, $value) {
- if (in_array($choice, ['qr', 'datamatrix'], true)) {
+ 'class' => BarcodeType::class,
+ 'choice_label' => fn(BarcodeType $choice) => match($choice) {
+ BarcodeType::NONE => 'label_options.barcode_type.none',
+ BarcodeType::QR => 'label_options.barcode_type.qr',
+ BarcodeType::CODE128 => 'label_options.barcode_type.code128',
+ BarcodeType::CODE39 => 'label_options.barcode_type.code39',
+ BarcodeType::CODE93 => 'label_options.barcode_type.code93',
+ BarcodeType::DATAMATRIX => 'label_options.barcode_type.datamatrix',
+ },
+ 'group_by' => static function (BarcodeType $choice, $key, $value): ?string {
+ if ($choice->is2D()) {
return 'label_options.barcode_type.2D';
}
- if (in_array($choice, ['code39', 'code93', 'code128'], true)) {
+ if ($choice->is1D()) {
return 'label_options.barcode_type.1D';
}
@@ -132,12 +134,13 @@ class LabelOptionsType extends AbstractType
'required' => false,
]);
- $builder->add('lines_mode', ChoiceType::class, [
+ $builder->add('process_mode', EnumType::class, [
'label' => 'label_options.lines_mode.label',
- 'choices' => [
- 'label_options.lines_mode.html' => 'html',
- 'label.options.lines_mode.twig' => 'twig',
- ],
+ 'class' => LabelProcessMode::class,
+ 'choice_label' => fn(LabelProcessMode $choice) => match($choice) {
+ LabelProcessMode::PLACEHOLDER => 'label_options.lines_mode.html',
+ LabelProcessMode::TWIG => 'label.options.lines_mode.twig',
+ },
'help' => 'label_options.lines_mode.help',
'help_html' => true,
'expanded' => true,
diff --git a/src/Form/LabelSystem/LabelDialogType.php b/src/Form/LabelSystem/LabelDialogType.php
index 9bc3dfdf..f2710b19 100644
--- a/src/Form/LabelSystem/LabelDialogType.php
+++ b/src/Form/LabelSystem/LabelDialogType.php
@@ -41,6 +41,7 @@ declare(strict_types=1);
namespace App\Form\LabelSystem;
+use Symfony\Bundle\SecurityBundle\Security;
use App\Form\LabelOptionsType;
use App\Validator\Constraints\Misc\ValidRange;
use Symfony\Component\Form\AbstractType;
@@ -48,15 +49,11 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
-use Symfony\Component\Security\Core\Security;
class LabelDialogType extends AbstractType
{
- protected Security $security;
-
- public function __construct(Security $security)
+ public function __construct(protected Security $security)
{
- $this->security = $security;
}
public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -74,6 +71,22 @@ class LabelDialogType extends AbstractType
'label' => false,
'disabled' => !$this->security->isGranted('@labels.edit_options') || $options['disable_options'],
]);
+
+ $builder->add('save_profile_name', TextType::class, [
+ 'required' => false,
+ 'attr' =>[
+ 'placeholder' => 'label_generator.save_profile_name',
+ ]
+ ]);
+
+ $builder->add('save_profile', SubmitType::class, [
+ 'label' => 'label_generator.save_profile',
+ 'disabled' => !$this->security->isGranted('@labels.create_profiles'),
+ 'attr' => [
+ 'class' => 'btn btn-outline-success'
+ ]
+ ]);
+
$builder->add('update', SubmitType::class, [
'label' => 'label_generator.update',
]);
diff --git a/src/Form/LabelSystem/ScanDialogType.php b/src/Form/LabelSystem/ScanDialogType.php
index 163ee9c2..13ff8e6f 100644
--- a/src/Form/LabelSystem/ScanDialogType.php
+++ b/src/Form/LabelSystem/ScanDialogType.php
@@ -41,7 +41,10 @@ declare(strict_types=1);
namespace App\Form\LabelSystem;
+use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
+use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -53,12 +56,34 @@ class ScanDialogType extends AbstractType
{
$builder->add('input', TextType::class, [
'label' => 'scan_dialog.input',
+ //Do not trim the input, otherwise this damages Format06 barcodes which end with non-printable characters
+ 'trim' => false,
'attr' => [
'autofocus' => true,
'id' => 'scan_dialog_input',
],
]);
+ $builder->add('mode', EnumType::class, [
+ 'label' => 'scan_dialog.mode',
+ 'expanded' => true,
+ 'class' => BarcodeSourceType::class,
+ 'required' => false,
+ 'placeholder' => 'scan_dialog.mode.auto',
+ 'choice_label' => fn (?BarcodeSourceType $enum) => match($enum) {
+ null => 'scan_dialog.mode.auto',
+ BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal',
+ BarcodeSourceType::IPN => 'scan_dialog.mode.ipn',
+ BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user',
+ BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp'
+ },
+ ]);
+
+ $builder->add('info_mode', CheckboxType::class, [
+ 'label' => 'scan_dialog.info_mode',
+ 'required' => false,
+ ]);
+
$builder->add('submit', SubmitType::class, [
'label' => 'scan_dialog.submit',
]);
diff --git a/src/Form/ParameterType.php b/src/Form/ParameterType.php
index 09293b97..4c2174ae 100644
--- a/src/Form/ParameterType.php
+++ b/src/Form/ParameterType.php
@@ -50,9 +50,10 @@ use App\Entity\Parameters\FootprintParameter;
use App\Entity\Parameters\GroupParameter;
use App\Entity\Parameters\ManufacturerParameter;
use App\Entity\Parameters\PartParameter;
-use App\Entity\Parameters\StorelocationParameter;
+use App\Entity\Parameters\StorageLocationParameter;
use App\Entity\Parameters\SupplierParameter;
use App\Entity\Parts\MeasurementUnit;
+use App\Form\Type\ExponentialNumberType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
@@ -93,7 +94,7 @@ class ParameterType extends AbstractType
],
]);
- $builder->add('value_max', NumberType::class, [
+ $builder->add('value_max', ExponentialNumberType::class, [
'label' => false,
'required' => false,
'html5' => true,
@@ -101,10 +102,10 @@ class ParameterType extends AbstractType
'step' => 'any',
'placeholder' => 'parameters.max.placeholder',
'class' => 'form-control-sm',
- 'style' => 'max-width: 12ch;',
+ 'style' => 'max-width: 25ch;',
],
]);
- $builder->add('value_min', NumberType::class, [
+ $builder->add('value_min', ExponentialNumberType::class, [
'label' => false,
'required' => false,
'html5' => true,
@@ -112,10 +113,10 @@ class ParameterType extends AbstractType
'step' => 'any',
'placeholder' => 'parameters.min.placeholder',
'class' => 'form-control-sm',
- 'style' => 'max-width: 12ch;',
+ 'style' => 'max-width: 25ch;',
],
]);
- $builder->add('value_typical', NumberType::class, [
+ $builder->add('value_typical', ExponentialNumberType::class, [
'label' => false,
'required' => false,
'html5' => true,
@@ -123,7 +124,7 @@ class ParameterType extends AbstractType
'step' => 'any',
'placeholder' => 'parameters.typical.placeholder',
'class' => 'form-control-sm',
- 'style' => 'max-width: 12ch;',
+ 'style' => 'max-width: 25ch;',
],
]);
$builder->add('unit', TextType::class, [
@@ -148,7 +149,7 @@ class ParameterType extends AbstractType
]);
}
- public function finishView(FormView $view, FormInterface $form, array $options)
+ public function finishView(FormView $view, FormInterface $form, array $options): void
{
//By default use part parameters for autocomplete
$view->vars['type'] = 'part';
@@ -163,7 +164,7 @@ class ParameterType extends AbstractType
GroupParameter::class => 'group',
ManufacturerParameter::class => 'manufacturer',
MeasurementUnit::class => 'measurement_unit',
- StorelocationParameter::class => 'storelocation',
+ StorageLocationParameter::class => 'storelocation',
SupplierParameter::class => 'supplier',
];
diff --git a/src/Form/Part/EDA/EDACategoryInfoType.php b/src/Form/Part/EDA/EDACategoryInfoType.php
new file mode 100644
index 00000000..f45bd697
--- /dev/null
+++ b/src/Form/Part/EDA/EDACategoryInfoType.php
@@ -0,0 +1,87 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Form\Part\EDA;
+
+use App\Entity\EDA\EDACategoryInfo;
+use App\Form\Type\TriStateCheckboxType;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\TextType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+use function Symfony\Component\Translation\t;
+
+class EDACategoryInfoType extends AbstractType
+{
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder
+ ->add('reference_prefix', TextType::class, [
+ 'label' => 'eda_info.reference_prefix',
+ 'attr' => [
+ 'placeholder' => t('eda_info.reference_prefix.placeholder'),
+ ]
+ ]
+ )
+ ->add('visibility', TriStateCheckboxType::class, [
+ 'help' => 'eda_info.visibility.help',
+ 'label' => 'eda_info.visibility',
+ ])
+ ->add('exclude_from_bom', TriStateCheckboxType::class, [
+ 'label' => 'eda_info.exclude_from_bom',
+ 'label_attr' => [
+ 'class' => 'checkbox-inline'
+ ]
+ ])
+ ->add('exclude_from_board', TriStateCheckboxType::class, [
+ 'label' => 'eda_info.exclude_from_board',
+ 'label_attr' => [
+ 'class' => 'checkbox-inline'
+ ]
+ ])
+ ->add('exclude_from_sim', TriStateCheckboxType::class, [
+ 'label' => 'eda_info.exclude_from_sim',
+ 'label_attr' => [
+ 'class' => 'checkbox-inline'
+ ]
+ ])
+ ->add('kicad_symbol', KicadFieldAutocompleteType::class, [
+ 'label' => 'eda_info.kicad_symbol',
+ 'type' => KicadFieldAutocompleteType::TYPE_SYMBOL,
+ 'attr' => [
+ 'placeholder' => t('eda_info.kicad_symbol.placeholder'),
+ ]
+ ])
+ ;
+
+
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => EDACategoryInfo::class,
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/src/Form/Part/EDA/EDAFootprintInfoType.php b/src/Form/Part/EDA/EDAFootprintInfoType.php
new file mode 100644
index 00000000..bdfa346c
--- /dev/null
+++ b/src/Form/Part/EDA/EDAFootprintInfoType.php
@@ -0,0 +1,55 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Form\Part\EDA;
+
+use App\Entity\EDA\EDAFootprintInfo;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+use function Symfony\Component\Translation\t;
+
+class EDAFootprintInfoType extends AbstractType
+{
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder
+ ->add('kicad_footprint', KicadFieldAutocompleteType::class, [
+ 'type' => KicadFieldAutocompleteType::TYPE_FOOTPRINT,
+ 'label' => 'eda_info.kicad_footprint',
+ 'attr' => [
+ 'placeholder' => t('eda_info.kicad_footprint.placeholder'),
+ ]
+ ]);
+
+
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => EDAFootprintInfo::class,
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/src/Form/Part/EDA/EDAPartInfoType.php b/src/Form/Part/EDA/EDAPartInfoType.php
new file mode 100644
index 00000000..e8cac681
--- /dev/null
+++ b/src/Form/Part/EDA/EDAPartInfoType.php
@@ -0,0 +1,97 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Form\Part\EDA;
+
+use App\Entity\EDA\EDAPartInfo;
+use App\Form\Type\TriStateCheckboxType;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\TextType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+use function Symfony\Component\Translation\t;
+
+class EDAPartInfoType extends AbstractType
+{
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder
+ ->add('reference_prefix', TextType::class, [
+ 'label' => 'eda_info.reference_prefix',
+ 'attr' => [
+ 'placeholder' => t('eda_info.reference_prefix.placeholder'),
+ ]
+ ]
+ )
+ ->add('value', TextType::class, [
+ 'label' => 'eda_info.value',
+ 'attr' => [
+ 'placeholder' => t('eda_info.value.placeholder'),
+ ]
+ ])
+ ->add('visibility', TriStateCheckboxType::class, [
+ 'help' => 'eda_info.visibility.help',
+ 'label' => 'eda_info.visibility',
+ ])
+ ->add('exclude_from_bom', TriStateCheckboxType::class, [
+ 'label' => 'eda_info.exclude_from_bom',
+ 'label_attr' => [
+ 'class' => 'checkbox-inline'
+ ]
+ ])
+ ->add('exclude_from_board', TriStateCheckboxType::class, [
+ 'label' => 'eda_info.exclude_from_board',
+ 'label_attr' => [
+ 'class' => 'checkbox-inline'
+ ]
+ ])
+ ->add('exclude_from_sim', TriStateCheckboxType::class, [
+ 'label' => 'eda_info.exclude_from_sim',
+ 'label_attr' => [
+ 'class' => 'checkbox-inline'
+ ]
+ ])
+ ->add('kicad_symbol', KicadFieldAutocompleteType::class, [
+ 'label' => 'eda_info.kicad_symbol',
+ 'type' => KicadFieldAutocompleteType::TYPE_SYMBOL,
+ 'attr' => [
+ 'placeholder' => t('eda_info.kicad_symbol.placeholder'),
+ ]
+ ])
+ ->add('kicad_footprint', KicadFieldAutocompleteType::class, [
+ 'label' => 'eda_info.kicad_footprint',
+ 'type' => KicadFieldAutocompleteType::TYPE_FOOTPRINT,
+ 'attr' => [
+ 'placeholder' => t('eda_info.kicad_footprint.placeholder'),
+ ]
+ ]);
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => EDAPartInfo::class,
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/src/Form/Part/EDA/KicadFieldAutocompleteType.php b/src/Form/Part/EDA/KicadFieldAutocompleteType.php
new file mode 100644
index 00000000..50de81d0
--- /dev/null
+++ b/src/Form/Part/EDA/KicadFieldAutocompleteType.php
@@ -0,0 +1,61 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Form\Part\EDA;
+
+use App\Form\Type\StaticFileAutocompleteType;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\OptionsResolver\Options;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+/**
+ * This is a specialized version of the StaticFileAutocompleteType, which loads the different types of Kicad lists.
+ */
+class KicadFieldAutocompleteType extends AbstractType
+{
+ public const TYPE_FOOTPRINT = 'footprint';
+ public const TYPE_SYMBOL = 'symbol';
+
+ //Do not use a leading slash here! otherwise it will not work under prefixed reverse proxies
+ public const FOOTPRINT_PATH = 'kicad/footprints.txt';
+ public const SYMBOL_PATH = 'kicad/symbols.txt';
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setRequired('type');
+ $resolver->setAllowedValues('type', [self::TYPE_SYMBOL, self::TYPE_FOOTPRINT]);
+
+ $resolver->setDefaults([
+ 'file' => fn(Options $options) => match ($options['type']) {
+ self::TYPE_FOOTPRINT => self::FOOTPRINT_PATH,
+ self::TYPE_SYMBOL => self::SYMBOL_PATH,
+ default => throw new \InvalidArgumentException('Invalid type'),
+ }
+ ]);
+ }
+
+ public function getParent(): string
+ {
+ return StaticFileAutocompleteType::class;
+ }
+}
\ No newline at end of file
diff --git a/src/Form/Part/OrderdetailType.php b/src/Form/Part/OrderdetailType.php
index 8489bc73..53240821 100644
--- a/src/Form/Part/OrderdetailType.php
+++ b/src/Form/Part/OrderdetailType.php
@@ -22,12 +22,12 @@ declare(strict_types=1);
namespace App\Form\Part;
+use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\PriceInformations\Pricedetail;
use App\Form\Type\StructuralEntityType;
-use App\Form\WorkaroundCollectionType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
@@ -37,15 +37,11 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
-use Symfony\Component\Security\Core\Security;
class OrderdetailType extends AbstractType
{
- protected Security $security;
-
- public function __construct(Security $security)
+ public function __construct(protected Security $security)
{
- $this->security = $security;
}
public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -83,7 +79,7 @@ class OrderdetailType extends AbstractType
$orderdetail = $event->getData();
$dummy_pricedetail = new Pricedetail();
- if (null !== $orderdetail && null !== $orderdetail->getSupplier()) {
+ if ($orderdetail instanceof Orderdetail && $orderdetail->getSupplier() instanceof Supplier) {
$dummy_pricedetail->setCurrency($orderdetail->getSupplier()->getDefaultCurrency());
}
diff --git a/src/Form/Part/PartAssociationType.php b/src/Form/Part/PartAssociationType.php
new file mode 100644
index 00000000..bf9ec4f9
--- /dev/null
+++ b/src/Form/Part/PartAssociationType.php
@@ -0,0 +1,73 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Form\Part;
+
+use App\Entity\Parts\AssociationType;
+use App\Entity\Parts\PartAssociation;
+use App\Form\Type\PartSelectType;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\EnumType;
+use Symfony\Component\Form\Extension\Core\Type\TextType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class PartAssociationType extends AbstractType
+{
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder
+ ->add('other', PartSelectType::class, [
+ 'label' => 'part_association.edit.other_part',
+ ])
+ ->add('type', EnumType::class, [
+ 'class' => AssociationType::class,
+ 'label' => 'part_association.edit.type',
+ 'choice_label' => fn(AssociationType $type) => $type->getTranslationKey(),
+ 'help' => 'part_association.edit.type.help',
+ 'attr' => [
+ 'data-pages--association-edit-type-select-target' => 'select'
+ ]
+ ])
+ ->add('other_type', TextType::class, [
+ 'required' => false,
+ 'label' => 'part_association.edit.other_type',
+ 'row_attr' => [
+ 'data-pages--association-edit-type-select-target' => 'display'
+ ]
+ ])
+ ->add('comment', TextType::class, [
+ 'required' => false,
+ 'label' => 'part_association.edit.comment'
+ ])
+ ;
+
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => PartAssociation::class,
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php
index 3951a2ac..b1d2ebea 100644
--- a/src/Form/Part/PartBaseType.php
+++ b/src/Form/Part/PartBaseType.php
@@ -27,20 +27,24 @@ use App\Entity\Parameters\PartParameter;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
+use App\Entity\Parts\ManufacturingStatus;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Orderdetail;
use App\Form\AttachmentFormType;
use App\Form\ParameterType;
+use App\Form\Part\EDA\EDAPartInfoType;
use App\Form\Type\MasterPictureAttachmentType;
use App\Form\Type\RichTextEditorType;
use App\Form\Type\SIUnitType;
use App\Form\Type\StructuralEntityType;
-use App\Form\WorkaroundCollectionType;
+use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
+use App\Services\LogSystem\EventCommentNeededHelper;
+use Symfony\Bundle\SecurityBundle\Security;
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;
@@ -48,32 +52,21 @@ 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\Component\Security\Core\Security;
class PartBaseType extends AbstractType
{
- protected Security $security;
- protected UrlGeneratorInterface $urlGenerator;
-
- public function __construct(Security $security, UrlGeneratorInterface $urlGenerator)
+ public function __construct(protected Security $security, protected UrlGeneratorInterface $urlGenerator, protected EventCommentNeededHelper $event_comment_needed_helper)
{
- $this->security = $security;
- $this->urlGenerator = $urlGenerator;
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
/** @var Part $part */
$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
@@ -105,13 +98,17 @@ 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,
+ //Do not require category for new parts, so that the user must select the category by hand and cannot forget it (the requirement is handled by the constraint in the entity)
+ 'required' => !$new_part,
])
->add('footprint', StructuralEntityType::class, [
'class' => Footprint::class,
'required' => false,
'label' => 'part.edit.footprint',
+ 'dto_value' => $dto?->footprint,
'allow_add' => $this->security->isGranted('@footprints.create'),
'disable_not_selectable' => true,
])
@@ -132,6 +129,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, [
@@ -144,9 +142,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,
]);
@@ -247,10 +246,26 @@ class PartBaseType extends AbstractType
],
]);
+ //Part associations
+ $builder->add('associated_parts_as_owner', CollectionType::class, [
+ 'entry_type' => PartAssociationType::class,
+ 'allow_add' => true,
+ 'allow_delete' => true,
+ 'reindex_enable' => true,
+ 'label' => false,
+ 'by_reference' => false,
+ ]);
+
+ //EDA info
+ $builder->add('eda_info', EDAPartInfoType::class, [
+ 'label' => false,
+ 'required' => false,
+ ]);
+
$builder->add('log_comment', TextType::class, [
'label' => 'edit.log_comment',
'mapped' => false,
- 'required' => false,
+ 'required' => $this->event_comment_needed_helper->isCommentNeeded($new_part ? 'part_create' : 'part_edit'),
'empty_data' => null,
]);
@@ -277,10 +292,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/Part/PartLotType.php b/src/Form/Part/PartLotType.php
index 2e23c94a..7d545340 100644
--- a/src/Form/Part/PartLotType.php
+++ b/src/Form/Part/PartLotType.php
@@ -22,26 +22,24 @@ declare(strict_types=1);
namespace App\Form\Part;
+use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\PartLot;
-use App\Entity\Parts\Storelocation;
+use App\Entity\Parts\StorageLocation;
use App\Form\Type\SIUnitType;
use App\Form\Type\StructuralEntityType;
+use App\Form\Type\UserSelectType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
-use Symfony\Component\Security\Core\Security;
class PartLotType extends AbstractType
{
- protected Security $security;
-
- public function __construct(Security $security)
+ public function __construct(protected Security $security)
{
- $this->security = $security;
}
public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -56,7 +54,7 @@ class PartLotType extends AbstractType
]);
$builder->add('storage_location', StructuralEntityType::class, [
- 'class' => Storelocation::class,
+ 'class' => StorageLocation::class,
'label' => 'part_lot.edit.location',
'required' => false,
'disable_not_selectable' => true,
@@ -82,7 +80,7 @@ class PartLotType extends AbstractType
'required' => false,
]);
- $builder->add('expirationDate', DateType::class, [
+ $builder->add('expiration_date', DateType::class, [
'label' => 'part_lot.edit.expiration_date',
'attr' => [],
'widget' => 'single_text',
@@ -98,6 +96,20 @@ class PartLotType extends AbstractType
'required' => false,
'empty_data' => '',
]);
+
+ $builder->add('owner', UserSelectType::class, [
+ 'label' => 'part_lot.owner',
+ 'required' => false,
+ 'help' => 'part_lot.owner.help',
+ ]);
+
+ $builder->add('user_barcode', TextType::class, [
+ 'label' => 'part_lot.edit.user_barcode',
+ 'help' => 'part_lot.edit.vendor_barcode.help',
+ 'required' => false,
+ //Do not remove whitespace chars on the beginning and end of the string
+ 'trim' => false,
+ ]);
}
public function configureOptions(OptionsResolver $resolver): void
diff --git a/src/Form/Part/PricedetailType.php b/src/Form/Part/PricedetailType.php
index c8df4c71..cabb112d 100644
--- a/src/Form/Part/PricedetailType.php
+++ b/src/Form/Part/PricedetailType.php
@@ -27,12 +27,18 @@ use App\Entity\PriceInformations\Pricedetail;
use App\Form\Type\BigDecimalNumberType;
use App\Form\Type\CurrencyEntityType;
use App\Form\Type\SIUnitType;
+use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PricedetailType extends AbstractType
{
+
+ public function __construct(private readonly Security $security)
+ {
+ }
+
public function buildForm(FormBuilderInterface $builder, array $options): void
{
//No labels needed, we define translation in templates
@@ -63,6 +69,7 @@ class PricedetailType extends AbstractType
'required' => false,
'label' => false,
'short' => true,
+ 'allow_add' => $this->security->isGranted('@currencies.create'),
]);
}
diff --git a/src/Form/PasswordTypeExtension.php b/src/Form/PasswordTypeExtension.php
new file mode 100644
index 00000000..64711c53
--- /dev/null
+++ b/src/Form/PasswordTypeExtension.php
@@ -0,0 +1,56 @@
+.
+ */
+namespace App\Form;
+
+use Symfony\Component\Form\AbstractTypeExtension;
+use Symfony\Component\Form\Extension\Core\Type\PasswordType;
+use Symfony\Component\Form\FormInterface;
+use Symfony\Component\Form\FormView;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+/**
+ * Purpose of this class is to add the setting 'password_estimator' to the PasswordType.
+ */
+class PasswordTypeExtension extends AbstractTypeExtension
+{
+
+ public static function getExtendedTypes(): iterable
+ {
+ return [PasswordType::class];
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'password_estimator' => false,
+ ]);
+
+ $resolver->setAllowedTypes('password_estimator', 'bool');
+ }
+
+ public function finishView(FormView $view, FormInterface $form, array $options): void
+ {
+ $view->vars['password_estimator'] = $options['password_estimator'];
+ }
+
+}
diff --git a/src/Form/Permissions/PermissionGroupType.php b/src/Form/Permissions/PermissionGroupType.php
index c395337f..f3f7ffec 100644
--- a/src/Form/Permissions/PermissionGroupType.php
+++ b/src/Form/Permissions/PermissionGroupType.php
@@ -30,12 +30,10 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class PermissionGroupType extends AbstractType
{
- protected PermissionManager $resolver;
protected array $perm_structure;
- public function __construct(PermissionManager $resolver)
+ public function __construct(protected PermissionManager $resolver)
{
- $this->resolver = $resolver;
$this->perm_structure = $resolver->getPermissionStructure();
}
@@ -68,9 +66,7 @@ class PermissionGroupType extends AbstractType
{
parent::configureOptions($resolver);
- $resolver->setDefault('group_name', static function (Options $options) {
- return trim($options['name']);
- });
+ $resolver->setDefault('group_name', static fn(Options $options): string => trim((string) $options['name']));
$resolver->setDefault('inherit', false);
diff --git a/src/Form/Permissions/PermissionType.php b/src/Form/Permissions/PermissionType.php
index 804fed0a..ab5ee86b 100644
--- a/src/Form/Permissions/PermissionType.php
+++ b/src/Form/Permissions/PermissionType.php
@@ -33,12 +33,10 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class PermissionType extends AbstractType
{
- protected PermissionManager $resolver;
protected array $perm_structure;
- public function __construct(PermissionManager $resolver)
+ public function __construct(protected PermissionManager $resolver)
{
- $this->resolver = $resolver;
$this->perm_structure = $resolver->getPermissionStructure();
}
@@ -46,9 +44,7 @@ class PermissionType extends AbstractType
{
parent::configureOptions($resolver);
- $resolver->setDefault('perm_name', static function (Options $options) {
- return $options['name'];
- });
+ $resolver->setDefault('perm_name', static fn(Options $options) => $options['name']);
$resolver->setDefault('label', function (Options $options) {
if (!empty($this->perm_structure['perms'][$options['perm_name']]['label'])) {
@@ -58,9 +54,7 @@ class PermissionType extends AbstractType
return $options['name'];
});
- $resolver->setDefault('multi_checkbox', static function (Options $options) {
- return !$options['disabled'];
- });
+ $resolver->setDefault('multi_checkbox', static fn(Options $options) => !$options['disabled']);
$resolver->setDefaults([
'inherit' => false,
diff --git a/src/Form/Permissions/PermissionsMapper.php b/src/Form/Permissions/PermissionsMapper.php
index c08d2954..d4b937bc 100644
--- a/src/Form/Permissions/PermissionsMapper.php
+++ b/src/Form/Permissions/PermissionsMapper.php
@@ -34,25 +34,20 @@ use Traversable;
*/
final class PermissionsMapper implements DataMapperInterface
{
- private PermissionManager $resolver;
- private bool $inherit;
-
- public function __construct(PermissionManager $resolver, bool $inherit = false)
+ public function __construct(private readonly PermissionManager $resolver, private readonly bool $inherit = false)
{
- $this->inherit = $inherit;
- $this->resolver = $resolver;
}
/**
- * Maps the view data of a compound form to its children.
+ * Maps the view data of a compound form to its children.
*
- * The method is responsible for calling {@link FormInterface::setData()}
- * on the children of compound forms, defining their underlying model data.
+ * The method is responsible for calling {@link FormInterface::setData()}
+ * on the children of compound forms, defining their underlying model data.
*
* @param mixed $viewData View data of the compound form being initialized
- * @param FormInterface[]|Traversable $forms A list of {@link FormInterface} instances
+ * @param Traversable $forms A list of {@link FormInterface} instances
*/
- public function mapDataToForms($viewData, $forms): void
+ public function mapDataToForms(mixed $viewData, \Traversable $forms): void
{
foreach ($forms as $form) {
if ($this->inherit) {
@@ -73,33 +68,33 @@ final class PermissionsMapper implements DataMapperInterface
}
/**
- * Maps the model data of a list of children forms into the view data of their parent.
+ * Maps the model data of a list of children forms into the view data of their parent.
*
- * This is the internal cascade call of FormInterface::submit for compound forms, since they
- * cannot be bound to any input nor the request as scalar, but their children may:
+ * This is the internal cascade call of FormInterface::submit for compound forms, since they
+ * cannot be bound to any input nor the request as scalar, but their children may:
*
- * $compoundForm->submit($arrayOfChildrenViewData)
- * // inside:
- * $childForm->submit($childViewData);
- * // for each entry, do the same and/or reverse transform
- * $this->dataMapper->mapFormsToData($compoundForm, $compoundInitialViewData)
- * // then reverse transform
+ * $compoundForm->submit($arrayOfChildrenViewData)
+ * // inside:
+ * $childForm->submit($childViewData);
+ * // for each entry, do the same and/or reverse transform
+ * $this->dataMapper->mapFormsToData($compoundForm, $compoundInitialViewData)
+ * // then reverse transform
*
- * When a simple form is submitted the following is happening:
+ * When a simple form is submitted the following is happening:
*
- * $simpleForm->submit($submittedViewData)
- * // inside:
- * $this->viewData = $submittedViewData
- * // then reverse transform
+ * $simpleForm->submit($submittedViewData)
+ * // inside:
+ * $this->viewData = $submittedViewData
+ * // then reverse transform
*
- * The model data can be an array or an object, so this second argument is always passed
- * by reference.
+ * The model data can be an array or an object, so this second argument is always passed
+ * by reference.
*
- * @param FormInterface[]|Traversable $forms A list of {@link FormInterface} instances
+ * @param Traversable $forms A list of {@link FormInterface} instances
* @param mixed $viewData The compound form's view data that get mapped
* its children model data
*/
- public function mapFormsToData($forms, &$viewData): void
+ public function mapFormsToData(\Traversable $forms, mixed &$viewData): void
{
if ($this->inherit) {
throw new RuntimeException('The permission type is readonly when it is showing read only data!');
diff --git a/src/Form/Permissions/PermissionsType.php b/src/Form/Permissions/PermissionsType.php
index e5688729..86fdbc2c 100644
--- a/src/Form/Permissions/PermissionsType.php
+++ b/src/Form/Permissions/PermissionsType.php
@@ -33,12 +33,10 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class PermissionsType extends AbstractType
{
- protected PermissionManager $resolver;
protected array $perm_structure;
- public function __construct(PermissionManager $resolver)
+ public function __construct(protected PermissionManager $resolver)
{
- $this->resolver = $resolver;
$this->perm_structure = $resolver->getPermissionStructure();
}
@@ -47,6 +45,7 @@ class PermissionsType extends AbstractType
$resolver->setDefaults([
'show_legend' => true,
'show_presets' => false,
+ 'show_dependency_notice' => static fn(Options $options) => !$options['disabled'],
'constraints' => static function (Options $options) {
if (!$options['disabled']) {
return [new NoLockout()];
@@ -62,6 +61,7 @@ class PermissionsType extends AbstractType
{
$view->vars['show_legend'] = $options['show_legend'];
$view->vars['show_presets'] = $options['show_presets'];
+ $view->vars['show_dependency_notice'] = $options['show_dependency_notice'];
}
public function buildForm(FormBuilderInterface $builder, array $options): void
diff --git a/src/Form/ProjectSystem/ProjectAddPartsType.php b/src/Form/ProjectSystem/ProjectAddPartsType.php
new file mode 100644
index 00000000..61f72c41
--- /dev/null
+++ b/src/Form/ProjectSystem/ProjectAddPartsType.php
@@ -0,0 +1,88 @@
+.
+ */
+namespace App\Form\ProjectSystem;
+
+use App\Entity\ProjectSystem\Project;
+use App\Entity\ProjectSystem\ProjectBOMEntry;
+use App\Form\Type\StructuralEntityType;
+use App\Validator\Constraints\UniqueObjectCollection;
+use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\SubmitType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\Form\FormEvent;
+use Symfony\Component\Form\FormEvents;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+use Symfony\Component\Validator\Constraints\NotNull;
+
+class ProjectAddPartsType extends AbstractType
+{
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder->add('project', StructuralEntityType::class, [
+ 'class' => Project::class,
+ 'required' => true,
+ 'disabled' => $options['project'] instanceof Project, //If a project is given, disable the field
+ 'data' => $options['project'],
+ 'constraints' => [
+ new NotNull()
+ ]
+ ]);
+ $builder->add('bom_entries', ProjectBOMEntryCollectionType::class, [
+ 'entry_options' => [
+ 'constraints' => [
+ new UniqueEntity(fields: ['part', 'project'], message: 'project.bom_entry.part_already_in_bom',
+ entityClass: ProjectBOMEntry::class),
+ new UniqueEntity(fields: ['name', 'project'], message: 'project.bom_entry.name_already_in_bom',
+ entityClass: ProjectBOMEntry::class, ignoreNull: true),
+ ]
+ ],
+ 'constraints' => [
+ new UniqueObjectCollection(message: 'project.bom_entry.part_already_in_bom', fields: ['part']),
+ new UniqueObjectCollection(message: 'project.bom_entry.name_already_in_bom', fields: ['name']),
+ ]
+ ]);
+ $builder->add('submit', SubmitType::class, ['label' => 'save']);
+
+ //After submit set the project for all bom entries, so that it can be validated properly
+ $builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event) {
+ $form = $event->getForm();
+ /** @var Project $project */
+ $project = $form->get('project')->getData();
+ $bom_entries = $form->get('bom_entries')->getData();
+
+ foreach ($bom_entries as $bom_entry) {
+ $bom_entry->setProject($project);
+ }
+ });
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'project' => null,
+ ]);
+
+ $resolver->setAllowedTypes('project', ['null', Project::class]);
+ }
+}
diff --git a/src/Form/ProjectSystem/ProjectBOMEntryCollectionType.php b/src/Form/ProjectSystem/ProjectBOMEntryCollectionType.php
index 71c745c7..53ec5f70 100644
--- a/src/Form/ProjectSystem/ProjectBOMEntryCollectionType.php
+++ b/src/Form/ProjectSystem/ProjectBOMEntryCollectionType.php
@@ -1,5 +1,7 @@
setDefaults([
'entry_type' => ProjectBOMEntryType::class,
@@ -27,9 +29,4 @@ class ProjectBOMEntryCollectionType extends AbstractType
'label' => false,
]);
}
-
- public function getBlockPrefix()
- {
- return 'project_bom_entry_collection';
- }
-}
\ No newline at end of file
+}
diff --git a/src/Form/ProjectSystem/ProjectBOMEntryType.php b/src/Form/ProjectSystem/ProjectBOMEntryType.php
index 49292235..cac362fb 100644
--- a/src/Form/ProjectSystem/ProjectBOMEntryType.php
+++ b/src/Form/ProjectSystem/ProjectBOMEntryType.php
@@ -1,19 +1,17 @@
ProjectBOMEntry::class,
]);
}
-
-
- public function getBlockPrefix()
- {
- return 'project_bom_entry';
- }
-}
\ No newline at end of file
+}
diff --git a/src/Form/ProjectSystem/ProjectBuildType.php b/src/Form/ProjectSystem/ProjectBuildType.php
index 3758bb21..2b7b52e2 100644
--- a/src/Form/ProjectSystem/ProjectBuildType.php
+++ b/src/Form/ProjectSystem/ProjectBuildType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\ProjectSystem;
+use Symfony\Bundle\SecurityBundle\Security;
+use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Form\Type\PartLotSelectType;
use App\Form\Type\SIUnitType;
@@ -34,18 +38,14 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
-use Symfony\Component\Security\Core\Security;
class ProjectBuildType extends AbstractType implements DataMapperInterface
{
- private Security $security;
-
- public function __construct(Security $security)
+ public function __construct(private readonly Security $security)
{
- $this->security = $security;
}
- public function configureOptions(OptionsResolver $resolver)
+ public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'compound' => true,
@@ -53,7 +53,7 @@ class ProjectBuildType extends AbstractType implements DataMapperInterface
]);
}
- public function buildForm(FormBuilderInterface $builder, array $options)
+ public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->setDataMapper($this);
@@ -62,6 +62,15 @@ class ProjectBuildType extends AbstractType implements DataMapperInterface
'disabled' => !$this->security->isGranted('@parts_stock.withdraw'),
]);
+ $builder->add('dontCheckQuantity', CheckboxType::class, [
+ 'label' => 'project.build.dont_check_quantity',
+ 'help' => 'project.build.dont_check_quantity.help',
+ 'required' => false,
+ 'attr' => [
+ 'data-controller' => 'pages--dont-check-quantity-checkbox'
+ ]
+ ]);
+
$builder->add('comment', TextType::class, [
'label' => 'part.info.withdraw_modal.comment',
'help' => 'part.info.withdraw_modal.comment.hint',
@@ -79,10 +88,10 @@ class ProjectBuildType extends AbstractType implements DataMapperInterface
$form->add('addBuildsToBuildsPart', CheckboxType::class, [
'label' => 'project.build.add_builds_to_builds_part',
'required' => false,
- 'disabled' => $build_request->getProject()->getBuildPart() === null,
+ 'disabled' => !$build_request->getProject()->getBuildPart() instanceof Part,
]);
- if ($build_request->getProject()->getBuildPart()) {
+ if ($build_request->getProject()->getBuildPart() instanceof Part) {
$form->add('buildsPartLot', PartLotSelectType::class, [
'label' => 'project.build.builds_part_lot',
'required' => false,
@@ -106,7 +115,7 @@ class ProjectBuildType extends AbstractType implements DataMapperInterface
});
}
- public function mapDataToForms($data, \Traversable $forms)
+ public function mapDataToForms($data, \Traversable $forms): void
{
if (!$data instanceof ProjectBuildRequest) {
throw new \RuntimeException('Data must be an instance of ' . ProjectBuildRequest::class);
@@ -124,6 +133,7 @@ class ProjectBuildType extends AbstractType implements DataMapperInterface
}
$forms['comment']->setData($data->getComment());
+ $forms['dontCheckQuantity']->setData($data->isDontCheckQuantity());
$forms['addBuildsToBuildsPart']->setData($data->getAddBuildsToBuildsPart());
if (isset($forms['buildsPartLot'])) {
$forms['buildsPartLot']->setData($data->getBuildsPartLot());
@@ -131,7 +141,7 @@ class ProjectBuildType extends AbstractType implements DataMapperInterface
}
- public function mapFormsToData(\Traversable $forms, &$data)
+ public function mapFormsToData(\Traversable $forms, &$data): void
{
if (!$data instanceof ProjectBuildRequest) {
throw new \RuntimeException('Data must be an instance of ' . ProjectBuildRequest::class);
@@ -145,17 +155,19 @@ class ProjectBuildType extends AbstractType implements DataMapperInterface
$matches = [];
if (preg_match('/^lot_(\d+)$/', $key, $matches)) {
$lot_id = (int) $matches[1];
- $data->setLotWithdrawAmount($lot_id, $form->getData());
+ $data->setLotWithdrawAmount($lot_id, (float) $form->getData());
}
}
$data->setComment($forms['comment']->getData());
+ $data->setDontCheckQuantity($forms['dontCheckQuantity']->getData());
+
if (isset($forms['buildsPartLot'])) {
$lot = $forms['buildsPartLot']->getData();
if (!$lot) { //When the user selected "Create new lot", create a new lot
$lot = new PartLot();
$description = 'Build ' . date('Y-m-d H:i:s');
- if (!empty($data->getComment())) {
+ if ($data->getComment() !== '') {
$description .= ' (' . $data->getComment() . ')';
}
$lot->setDescription($description);
@@ -168,4 +180,4 @@ class ProjectBuildType extends AbstractType implements DataMapperInterface
//This has to be set after the builds part lot, so that it can disable the option
$data->setAddBuildsToBuildsPart($forms['addBuildsToBuildsPart']->getData());
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/TFAGoogleSettingsType.php b/src/Form/TFAGoogleSettingsType.php
index e00ba494..7917f705 100644
--- a/src/Form/TFAGoogleSettingsType.php
+++ b/src/Form/TFAGoogleSettingsType.php
@@ -53,6 +53,7 @@ class TFAGoogleSettingsType extends AbstractType
'google_confirmation',
TextType::class,
[
+ 'label' => 'tfa.check.code.confirmation',
'mapped' => false,
'attr' => [
'maxlength' => '6',
@@ -60,7 +61,7 @@ class TFAGoogleSettingsType extends AbstractType
'pattern' => '\d*',
'autocomplete' => 'off',
],
- 'constraints' => [new ValidGoogleAuthCode()],
+ 'constraints' => [new ValidGoogleAuthCode(groups: ["google_authenticator"])],
]
);
@@ -92,6 +93,7 @@ class TFAGoogleSettingsType extends AbstractType
{
$resolver->setDefaults([
'data_class' => User::class,
+ 'validation_groups' => ['google_authenticator'],
]);
}
}
diff --git a/src/Form/Type/BigDecimalMoneyType.php b/src/Form/Type/BigDecimalMoneyType.php
index 2fb8d7ee..189416ff 100644
--- a/src/Form/Type/BigDecimalMoneyType.php
+++ b/src/Form/Type/BigDecimalMoneyType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Type;
use Brick\Math\BigDecimal;
@@ -28,7 +30,7 @@ use Symfony\Component\Form\FormBuilderInterface;
class BigDecimalMoneyType extends AbstractType implements DataTransformerInterface
{
- public function buildForm(FormBuilderInterface $builder, array $options)
+ public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addModelTransformer($this);
}
diff --git a/src/Form/Type/BigDecimalNumberType.php b/src/Form/Type/BigDecimalNumberType.php
index 8ee0911e..c225f0a4 100644
--- a/src/Form/Type/BigDecimalNumberType.php
+++ b/src/Form/Type/BigDecimalNumberType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Type;
use Brick\Math\BigDecimal;
@@ -28,7 +30,7 @@ use Symfony\Component\Form\FormBuilderInterface;
class BigDecimalNumberType extends AbstractType implements DataTransformerInterface
{
- public function buildForm(FormBuilderInterface $builder, array $options)
+ public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addModelTransformer($this);
}
diff --git a/src/Form/Type/CurrencyEntityType.php b/src/Form/Type/CurrencyEntityType.php
index 856acc9e..07f0a9f8 100644
--- a/src/Form/Type/CurrencyEntityType.php
+++ b/src/Form/Type/CurrencyEntityType.php
@@ -22,14 +22,10 @@ declare(strict_types=1);
namespace App\Form\Type;
-use App\Entity\Attachments\AttachmentType;
-use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\PriceInformations\Currency;
use App\Form\Type\Helper\StructuralEntityChoiceHelper;
-use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Trees\NodesListBuilder;
use Doctrine\ORM\EntityManagerInterface;
-use RuntimeException;
use Symfony\Component\Intl\Currencies;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -40,12 +36,9 @@ use Symfony\Contracts\Translation\TranslatorInterface;
*/
class CurrencyEntityType extends StructuralEntityType
{
- protected ?string $base_currency;
-
- public function __construct(EntityManagerInterface $em, NodesListBuilder $builder, TranslatorInterface $translator, StructuralEntityChoiceHelper $choiceHelper, ?string $base_currency)
+ public function __construct(EntityManagerInterface $em, NodesListBuilder $builder, TranslatorInterface $translator, StructuralEntityChoiceHelper $choiceHelper, protected ?string $base_currency)
{
parent::__construct($em, $builder, $translator, $choiceHelper);
- $this->base_currency = $base_currency;
}
public function configureOptions(OptionsResolver $resolver): void
@@ -60,14 +53,10 @@ class CurrencyEntityType extends StructuralEntityType
// This options allows you to override the currency shown for the null value
$resolver->setDefault('base_currency', null);
- $resolver->setDefault('choice_attr', function (Options $options) {
- return function ($choice) use ($options) {
- return $this->choice_helper->generateChoiceAttrCurrency($choice, $options);
- };
- });
+ $resolver->setDefault('choice_attr', fn(Options $options) => fn($choice) => $this->choice_helper->generateChoiceAttrCurrency($choice, $options));
$resolver->setDefault('empty_message', function (Options $options) {
- //By default we use the global base currency:
+ //By default, we use the global base currency:
$iso_code = $this->base_currency;
if ($options['base_currency']) { //Allow to override it
@@ -79,7 +68,7 @@ class CurrencyEntityType extends StructuralEntityType
$resolver->setDefault('used_to_select_parent', false);
- //If short is set to true, then the name of the entity will only shown in the dropdown list not in the selected value.
+ //If short is set to true, then the name of the entity will only show in the dropdown list not in the selected value.
$resolver->setDefault('short', false);
}
}
diff --git a/src/Form/Type/ExponentialNumberType.php b/src/Form/Type/ExponentialNumberType.php
new file mode 100644
index 00000000..f566afbb
--- /dev/null
+++ b/src/Form/Type/ExponentialNumberType.php
@@ -0,0 +1,61 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Form\Type;
+
+use App\Form\Type\Helper\ExponentialNumberTransformer;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\NumberType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+/**
+ * Similar to the NumberType, but formats small values in scienfitic notation instead of rounding it to 0, like NumberType
+ */
+class ExponentialNumberType extends AbstractType
+{
+ public function getParent(): string
+ {
+ return NumberType::class;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ //We want to allow the full precision of the number, so disable rounding
+ 'scale' => null,
+ ]);
+ }
+
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder->resetViewTransformers();
+
+ $builder->addViewTransformer(new ExponentialNumberTransformer(
+ $options['scale'],
+ $options['grouping'],
+ $options['rounding_mode'],
+ $options['html5'] ? 'en' : null
+ ));
+ }
+}
\ No newline at end of file
diff --git a/src/Form/Type/Helper/ExponentialNumberTransformer.php b/src/Form/Type/Helper/ExponentialNumberTransformer.php
new file mode 100644
index 00000000..ee2f4a4c
--- /dev/null
+++ b/src/Form/Type/Helper/ExponentialNumberTransformer.php
@@ -0,0 +1,113 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Form\Type\Helper;
+
+use Symfony\Component\Form\Exception\TransformationFailedException;
+use Symfony\Component\Form\Extension\Core\DataTransformer\NumberToLocalizedStringTransformer;
+
+/**
+ * This transformer formats small values in scienfitic notation instead of rounding it to 0, like the default
+ * NumberFormatter.
+ */
+class ExponentialNumberTransformer extends NumberToLocalizedStringTransformer
+{
+ public function __construct(
+ private ?int $scale = null,
+ ?bool $grouping = false,
+ ?int $roundingMode = \NumberFormatter::ROUND_HALFUP,
+ protected ?string $locale = null
+ ) {
+ //Set scale to null, to disable rounding of values
+ parent::__construct($scale, $grouping, $roundingMode, $locale);
+ }
+
+ /**
+ * Transforms a number type into localized number.
+ *
+ * @param int|float|null $value Number value
+ *
+ * @throws TransformationFailedException if the given value is not numeric
+ * or if the value cannot be transformed
+ */
+ public function transform(mixed $value): string
+ {
+ if (null === $value) {
+ return '';
+ }
+
+ if (!is_numeric($value)) {
+ throw new TransformationFailedException('Expected a numeric.');
+ }
+
+ //If the value is too small, the number formatter would return 0, therfore use exponential notation for small numbers
+ if (abs($value) < 1e-3) {
+ $formatter = $this->getScientificNumberFormatter();
+ } else {
+ $formatter = $this->getNumberFormatter();
+ }
+
+
+
+ $value = $formatter->format($value);
+
+ if (intl_is_failure($formatter->getErrorCode())) {
+ throw new TransformationFailedException($formatter->getErrorMessage());
+ }
+
+ // Convert non-breaking and narrow non-breaking spaces to normal ones
+ $value = str_replace(["\xc2\xa0", "\xe2\x80\xaf"], ' ', $value);
+
+ return $value;
+ }
+
+ protected function getScientificNumberFormatter(): \NumberFormatter
+ {
+ $formatter = new \NumberFormatter($this->locale ?? \Locale::getDefault(), \NumberFormatter::SCIENTIFIC);
+
+ if (null !== $this->scale) {
+ $formatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, $this->scale);
+ $formatter->setAttribute(\NumberFormatter::ROUNDING_MODE, $this->roundingMode);
+ }
+
+ $formatter->setAttribute(\NumberFormatter::GROUPING_USED, (int) $this->grouping);
+
+ return $formatter;
+ }
+
+ protected function getNumberFormatter(): \NumberFormatter
+ {
+ $formatter = parent::getNumberFormatter();
+
+ //Unset the fraction digits, as we don't want to round the number
+ $formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, 0);
+ if (null !== $this->scale) {
+ $formatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, $this->scale);
+ } else {
+ $formatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, 100);
+ }
+
+
+ return $formatter;
+ }
+}
\ No newline at end of file
diff --git a/src/Form/Type/Helper/StructuralEntityChoiceHelper.php b/src/Form/Type/Helper/StructuralEntityChoiceHelper.php
index db25e6da..1210d188 100644
--- a/src/Form/Type/Helper/StructuralEntityChoiceHelper.php
+++ b/src/Form/Type/Helper/StructuralEntityChoiceHelper.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Type\Helper;
+use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
+use App\Entity\Base\AbstractDBElement;
+use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
+use App\Entity\Contracts\HasMasterAttachmentInterface;
use App\Entity\PriceInformations\Currency;
use App\Services\Attachments\AttachmentURLGenerator;
-use RuntimeException;
use Symfony\Component\Intl\Currencies;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -32,124 +37,116 @@ use Symfony\Contracts\Translation\TranslatorInterface;
class StructuralEntityChoiceHelper
{
- private AttachmentURLGenerator $attachmentURLGenerator;
- private TranslatorInterface $translator;
-
- public function __construct(AttachmentURLGenerator $attachmentURLGenerator, TranslatorInterface $translator)
+ public function __construct(private readonly AttachmentURLGenerator $attachmentURLGenerator, private readonly TranslatorInterface $translator)
{
- $this->attachmentURLGenerator = $attachmentURLGenerator;
- $this->translator = $translator;
}
/**
* Generates the choice attributes for the given AbstractStructuralDBElement.
- * @param AbstractStructuralDBElement $choice
- * @param Options|array $options
- * @return array|string[]
+ * @return array
*/
- public function generateChoiceAttr(AbstractStructuralDBElement $choice, $options): array
+ public function generateChoiceAttr(AbstractNamedDBElement $choice, Options|array $options): array
{
- $tmp = [];
-
- //Disable attribute if the choice is marked as not selectable
- if (($options['disable_not_selectable'] ?? false) && $choice->isNotSelectable()) {
- $tmp += ['disabled' => 'disabled'];
- }
-
- if ($choice instanceof AttachmentType) {
- $tmp += ['data-filetype_filter' => $choice->getFiletypeFilter()];
- }
-
- $level = $choice->getLevel();
- /** @var AbstractStructuralDBElement|null $parent */
- $parent = $options['subentities_of'] ?? null;
- if (null !== $parent) {
- $level -= $parent->getLevel() - 1;
- }
-
- $tmp += [
- 'data-level' => $level,
- 'data-parent' => $choice->getParent() ? $choice->getParent()->getFullPath() : null,
- 'data-path' => $choice->getFullPath('->'),
- 'data-image' => $choice->getMasterPictureAttachment() ? $this->attachmentURLGenerator->getThumbnailURL($choice->getMasterPictureAttachment(), 'thumbnail_xs') : null,
+ $tmp = [
+ 'data-level' => 0,
+ 'data-path' => $choice->getName(),
];
- if ($choice instanceof AttachmentType && !empty($choice->getFiletypeFilter())) {
+ if ($choice instanceof AbstractStructuralDBElement) {
+ //Disable attribute if the choice is marked as not selectable
+ if (($options['disable_not_selectable'] ?? false) && $choice->isNotSelectable()) {
+ $tmp += ['disabled' => 'disabled'];
+ }
+
+ if ($choice instanceof AttachmentType) {
+ $tmp += ['data-filetype_filter' => $choice->getFiletypeFilter()];
+ }
+
+ $level = $choice->getLevel();
+ /** @var AbstractStructuralDBElement|null $parent */
+ $parent = $options['subentities_of'] ?? null;
+ if ($parent instanceof AbstractStructuralDBElement) {
+ $level -= $parent->getLevel() - 1;
+ }
+
+ $tmp['data-level'] = $level;
+ $tmp['data-parent'] = $choice->getParent() instanceof AbstractStructuralDBElement ? $choice->getParent()->getFullPath() : null;
+ $tmp['data-path'] = $choice->getFullPath('->');
+ }
+
+ if ($choice instanceof HasMasterAttachmentInterface) {
+ $tmp['data-image'] = ($choice->getMasterPictureAttachment() instanceof Attachment
+ && $choice->getMasterPictureAttachment()->isPicture()) ?
+ $this->attachmentURLGenerator->getThumbnailURL($choice->getMasterPictureAttachment(),
+ 'thumbnail_xs')
+ : null
+ ;
+ }
+
+ if ($choice instanceof AttachmentType && $choice->getFiletypeFilter() !== '') {
$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;
}
/**
* Generates the choice attributes for the given AbstractStructuralDBElement.
- * @param Currency $choice
- * @param Options|array $options
* @return array|string[]
*/
- public function generateChoiceAttrCurrency(Currency $choice, $options): array
+ public function generateChoiceAttrCurrency(Currency $choice, Options|array $options): array
{
$tmp = $this->generateChoiceAttr($choice, $options);
+ $symbol = $choice->getIsoCode() === '' ? null : Currencies::getSymbol($choice->getIsoCode());
+ $tmp['data-short'] = $options['short'] ? $symbol : $choice->getName();
- if(!empty($choice->getIsoCode())) {
- $symbol = Currencies::getSymbol($choice->getIsoCode());
- } else {
- $symbol = null;
- }
+ //Show entities that are not added to DB yet separately from other entities
+ $tmp['data-not_in_db_yet'] = $choice->getID() === null;
- if ($options['short']) {
- $tmp['data-short'] = $symbol;
- } else {
- $tmp['data-short'] = $choice->getName();
- }
-
- $tmp += [
+ return $tmp + [
'data-symbol' => $symbol,
];
-
- return $tmp;
}
/**
* Returns the choice label for the given AbstractStructuralDBElement.
- * @param AbstractStructuralDBElement $choice
- * @return string
*/
- public function generateChoiceLabel(AbstractStructuralDBElement $choice): string
+ public function generateChoiceLabel(AbstractNamedDBElement $choice): string
{
return $choice->getName();
}
/**
* Returns the choice value for the given AbstractStructuralDBElement.
- * @param AbstractStructuralDBElement|null $element
- * @return string|int|null
*/
- public function generateChoiceValue(?AbstractStructuralDBElement $element)
+ public function generateChoiceValue(?AbstractNamedDBElement $element): string|int|null
{
- if ($element === null) {
+ if (!$element instanceof AbstractNamedDBElement) {
return null;
}
/**
* Do not change the structure below, even when inspection says it can be replaced with a null coalescing operator.
- * It is important that the value returned here for a existing element is an int, and for a new element a string.
- * I dont really understand why, but it seems to be important for the choice_loader to work correctly.
+ * It is important that the value returned here for an existing element is an int, and for a new element a string.
+ * I don't really understand why, but it seems to be important for the choice_loader to work correctly.
* So please do not change this!
*/
if ($element->getID() === null) {
- //Must be the same as the separator in the choice_loader, otherwise this will not work!
- return $element->getFullPath('->');
+ if ($element instanceof AbstractStructuralDBElement) {
+ //Must be the same as the separator in the choice_loader, otherwise this will not work!
+ return '$%$' . $element->getFullPath('->');
+ }
+ // '$%$' is the indicator prefix for a new entity
+ return '$%$' . $element->getName();
}
return $element->getID();
}
- /**
- * @param AbstractStructuralDBElement $element
- * @return string|null
- */
- public function generateGroupBy(AbstractStructuralDBElement $element): ?string
+ public function generateGroupBy(AbstractDBElement $element): ?string
{
//Show entities that are not added to DB yet separately from other entities
if ($element->getID() === null) {
@@ -158,4 +155,4 @@ class StructuralEntityChoiceHelper
return null;
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/Type/Helper/StructuralEntityChoiceLoader.php b/src/Form/Type/Helper/StructuralEntityChoiceLoader.php
index 6bca91b4..e2e4e841 100644
--- a/src/Form/Type/Helper/StructuralEntityChoiceLoader.php
+++ b/src/Form/Type/Helper/StructuralEntityChoiceLoader.php
@@ -1,4 +1,7 @@
options = $options;
- $this->builder = $builder;
- $this->entityManager = $entityManager;
+ private ?AbstractNamedDBElement $starting_element = null;
+
+ private ?FormInterface $form = null;
+
+ public function __construct(
+ private readonly Options $options,
+ private readonly NodesListBuilder $builder,
+ private readonly EntityManagerInterface $entityManager,
+ private readonly TranslatorInterface $translator
+ ) {
}
protected function loadChoices(): iterable
{
- $tmp = [];
+ //If the starting_element is set and not persisted yet, add it to the list
+ $tmp = $this->starting_element !== null && $this->starting_element->getID() === null ? [$this->starting_element] : [];
+
if ($this->additional_element) {
$tmp = $this->createNewEntitiesFromValue($this->additional_element);
$this->additional_element = null;
@@ -56,26 +68,51 @@ 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']) {
+ //If we have a form, add an error to it, to improve the user experience
+ if ($this->form !== null) {
+ $this->form->addError(
+ new FormError($this->translator->trans('entity.select.creating_new_entities_not_allowed')
+ )
+ );
+ } else {
+ throw new \RuntimeException('Cannot create new entity, because allow_add is not enabled!');
+ }
+ }
+
+
+ /** @var class-string $class */
$class = $this->options['class'];
- /** @var StructuralDBElementRepository $repo */
+
+ /** @var StructuralDBElementRepository $repo */
$repo = $this->entityManager->getRepository($class);
+
$entities = $repo->getNewEntityFromPath($value, '->');
$results = [];
- foreach($entities as $entity) {
+ foreach ($entities as $entity) {
//If the entity is newly created (ID null), add it as result and persist it.
if ($entity->getID() === null) {
- $this->entityManager->persist($entity);
+ //Only persist the entities if it is allowed
+ if ($this->options['allow_add']) {
+ $this->entityManager->persist($entity);
+ }
$results[] = $entity;
}
}
@@ -93,4 +130,50 @@ class StructuralEntityChoiceLoader extends AbstractChoiceLoader
return $this->additional_element;
}
-}
\ No newline at end of file
+ /**
+ * Gets the initial value used to populate the field.
+ * @return AbstractNamedDBElement|null
+ */
+ public function getStartingElement(): ?AbstractNamedDBElement
+ {
+ return $this->starting_element;
+ }
+
+ /**
+ * Sets the form that this loader is bound to.
+ * @param FormInterface|null $form
+ * @return void
+ */
+ public function setForm(?FormInterface $form): void
+ {
+ $this->form = $form;
+ }
+
+ /**
+ * Sets the initial value used to populate the field. This will always be an allowed value.
+ * @param AbstractNamedDBElement|null $starting_element
+ * @return StructuralEntityChoiceLoader
+ */
+ public function setStartingElement(?AbstractNamedDBElement $starting_element): StructuralEntityChoiceLoader
+ {
+ $this->starting_element = $starting_element;
+ return $this;
+ }
+
+ protected function doLoadChoicesForValues(array $values, ?callable $value): array
+ {
+ // Normalize the data (remove whitespaces around the arrow sign) and leading/trailing whitespaces
+ // This is required so that the value that is generated for an new entity based on its name structure is
+ // the same as the value that is generated for the same entity after it is persisted.
+ // Otherwise, errors occurs that the element could not be found.
+ foreach ($values as &$data) {
+ $data = trim((string) $data);
+ $data = preg_replace('/\s*->\s*/', '->', $data);
+ }
+ unset ($data);
+
+ return $this->loadChoiceList($value)->getChoicesForValues($values);
+ }
+
+
+}
diff --git a/src/Form/Type/MasterPictureAttachmentType.php b/src/Form/Type/MasterPictureAttachmentType.php
index 7aba9cc6..b5edbd55 100644
--- a/src/Form/Type/MasterPictureAttachmentType.php
+++ b/src/Form/Type/MasterPictureAttachmentType.php
@@ -42,32 +42,28 @@ class MasterPictureAttachmentType extends AbstractType
$resolver->setDefaults([
'filter' => 'picture',
'choice_translation_domain' => false,
- 'choice_attr' => static function (Options $options) {
- return static function ($choice, $key, $value) use ($options) {
- /** @var Attachment $choice */
- $tmp = ['data-subtext' => $choice->getFilename() ?? 'URL'];
+ 'choice_attr' => static fn(Options $options) => static function ($choice, $key, $value) use ($options) {
+ /** @var Attachment $choice */
+ $tmp = ['data-subtext' => $choice->getFilename() ?? 'URL'];
- if ('picture' === $options['filter'] && !$choice->isPicture()) {
- $tmp += ['disabled' => 'disabled'];
- } elseif ('3d_model' === $options['filter'] && !$choice->is3DModel()) {
- $tmp += ['disabled' => 'disabled'];
- }
+ if ('picture' === $options['filter'] && !$choice->isPicture()) {
+ $tmp += ['disabled' => 'disabled'];
+ } elseif ('3d_model' === $options['filter'] && !$choice->is3DModel()) {
+ $tmp += ['disabled' => 'disabled'];
+ }
- return $tmp;
- };
+ return $tmp;
},
'choice_label' => 'name',
- 'choice_loader' => static function (Options $options) {
- return new CallbackChoiceLoader(
- static function () use ($options) {
- $entity = $options['entity'];
- if (!$entity instanceof AttachmentContainingDBElement) {
- throw new RuntimeException('$entity must have Attachments! (be of type AttachmentContainingDBElement)');
- }
+ 'choice_loader' => static fn(Options $options) => new CallbackChoiceLoader(
+ static function () use ($options) {
+ $entity = $options['entity'];
+ if (!$entity instanceof AttachmentContainingDBElement) {
+ throw new RuntimeException('$entity must have Attachments! (be of type AttachmentContainingDBElement)');
+ }
- return $entity->getAttachments()->toArray();
- });
- },
+ return $entity->getAttachments()->toArray();
+ }),
]);
$resolver->setAllowedValues('filter', ['', 'picture', '3d_model']);
diff --git a/src/Form/Type/PartLotSelectType.php b/src/Form/Type/PartLotSelectType.php
index 61e119b0..c68535a7 100644
--- a/src/Form/Type/PartLotSelectType.php
+++ b/src/Form/Type/PartLotSelectType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Type;
+use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use Doctrine\ORM\EntityRepository;
@@ -31,29 +34,23 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class PartLotSelectType extends AbstractType
{
- public function getParent()
+ public function getParent(): string
{
return EntityType::class;
}
- public function configureOptions(OptionsResolver $resolver)
+ public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setRequired('part');
$resolver->setAllowedTypes('part', Part::class);
$resolver->setDefaults([
'class' => PartLot::class,
- 'choice_label' => ChoiceList::label($this, function (PartLot $part_lot) {
- return ($part_lot->getStorageLocation() ? $part_lot->getStorageLocation()->getFullPath() : '')
- . ' (' . $part_lot->getName() . '): ' . $part_lot->getAmount();
- }),
- 'query_builder' => function (Options $options) {
- return function (EntityRepository $er) use ($options) {
- return $er->createQueryBuilder('l')
- ->where('l.part = :part')
- ->setParameter('part', $options['part']);
- };
- }
+ 'choice_label' => ChoiceList::label($this, static fn(PartLot $part_lot): string => ($part_lot->getStorageLocation() instanceof StorageLocation ? $part_lot->getStorageLocation()->getFullPath() : '')
+ . ' (' . $part_lot->getName() . '): ' . $part_lot->getAmount()),
+ 'query_builder' => fn(Options $options) => static fn(EntityRepository $er) => $er->createQueryBuilder('l')
+ ->where('l.part = :part')
+ ->setParameter('part', $options['part'])
]);
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/Type/PartSelectType.php b/src/Form/Type/PartSelectType.php
index 5ca543f7..34b8fc7c 100644
--- a/src/Form/Type/PartSelectType.php
+++ b/src/Form/Type/PartSelectType.php
@@ -1,7 +1,12 @@
urlGenerator = $urlGenerator;
- $this->em = $em;
- $this->previewGenerator = $previewGenerator;
- $this->attachmentURLGenerator = $attachmentURLGenerator;
}
- public function buildForm(FormBuilderInterface $builder, array $options)
+ public function buildForm(FormBuilderInterface $builder, array $options): void
{
- //At initialization we have to fill the form element with our selected data, so the user can see it
+ //At initialization, we have to fill the form element with our selected data, so the user can see it
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (PreSetDataEvent $event) {
$form = $event->getForm();
$config = $form->getConfig()->getOptions();
@@ -73,7 +68,7 @@ class PartSelectType extends AbstractType implements DataMapperInterface
$builder->setDataMapper($this);
}
- public function configureOptions(OptionsResolver $resolver)
+ public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'class' => Part::class,
@@ -96,10 +91,10 @@ class PartSelectType extends AbstractType implements DataMapperInterface
$resolver->setDefaults([
//Prefill the selected choice with the needed data, so the user can see it without an additional Ajax request
'choice_attr' => ChoiceList::attr($this, function (?Part $part) {
- if($part) {
+ if($part instanceof Part) {
//Determine the picture to show:
$preview_attachment = $this->previewGenerator->getTablePreviewAttachment($part);
- if ($preview_attachment !== null) {
+ if ($preview_attachment instanceof Attachment) {
$preview_url = $this->attachmentURLGenerator->getThumbnailURL($preview_attachment,
'thumbnail_sm');
} else {
@@ -107,31 +102,26 @@ class PartSelectType extends AbstractType implements DataMapperInterface
}
}
- return $part ? [
+ return $part instanceof Part ? [
'data-description' => mb_strimwidth($part->getDescription(), 0, 127, '...'),
- 'data-category' => $part->getCategory() ? $part->getCategory()->getName() : '',
- 'data-footprint' => $part->getFootprint() ? $part->getFootprint()->getName() : '',
+ 'data-category' => $part->getCategory() instanceof Category ? $part->getCategory()->getName() : '',
+ 'data-footprint' => $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getName() : '',
'data-image' => $preview_url,
] : [];
})
]);
}
- public function getBlockPrefix()
- {
- return 'part_select';
- }
-
- public function mapDataToForms($data, $forms)
+ public function mapDataToForms($data, \Traversable $forms): void
{
$form = current(iterator_to_array($forms, false));
$form->setData($data);
}
- public function mapFormsToData($forms, &$data)
+ public function mapFormsToData(\Traversable $forms, &$data): void
{
$form = current(iterator_to_array($forms, false));
$data = $form->getData();
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/Type/RichTextEditorType.php b/src/Form/Type/RichTextEditorType.php
index a572fa2e..50683b0c 100644
--- a/src/Form/Type/RichTextEditorType.php
+++ b/src/Form/Type/RichTextEditorType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
@@ -28,7 +30,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class RichTextEditorType extends AbstractType
{
- public function configureOptions(OptionsResolver $resolver)
+ public function configureOptions(OptionsResolver $resolver): void
{
parent::configureOptions($resolver); // TODO: Change the autogenerated stub
@@ -39,12 +41,7 @@ class RichTextEditorType extends AbstractType
}
- public function getBlockPrefix(): string
- {
- return 'rich_text_editor';
- }
-
- public function finishView(FormView $view, FormInterface $form, array $options)
+ public function finishView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['attr'] = array_merge($view->vars['attr'], $this->optionsToAttrArray($options));
@@ -53,22 +50,17 @@ class RichTextEditorType extends AbstractType
protected function optionsToAttrArray(array $options): array
{
- $tmp = [];
-
- //Set novalidate attribute, or we will get problems that form can not be submitted as textarea is not focusable
- $tmp['novalidate'] = 'novalidate';
-
- $tmp['data-mode'] = $options['mode'];
-
- //Add our data-controller element to the textarea
- $tmp['data-controller'] = 'elements--ckeditor';
-
-
- return $tmp;
+ return [
+ //Set novalidate attribute, or we will get problems that form can not be submitted as textarea is not focusable
+ 'novalidate' => 'novalidate',
+ 'data-mode' => $options['mode'],
+ //Add our data-controller element to the textarea
+ 'data-controller' => 'elements--ckeditor',
+ ];
}
public function getParent(): string
{
return TextareaType::class;
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/Type/SIUnitType.php b/src/Form/Type/SIUnitType.php
index bfec23e2..52bcaa3d 100644
--- a/src/Form/Type/SIUnitType.php
+++ b/src/Form/Type/SIUnitType.php
@@ -38,11 +38,8 @@ use Traversable;
final class SIUnitType extends AbstractType implements DataMapperInterface
{
- protected SIFormatter $si_formatter;
-
- public function __construct(SIFormatter $SIFormatter)
+ public function __construct(protected SIFormatter $si_formatter)
{
- $this->si_formatter = $SIFormatter;
}
public function configureOptions(OptionsResolver $resolver): void
@@ -91,7 +88,7 @@ final class SIUnitType extends AbstractType implements DataMapperInterface
$resolver->setDefaults([
'min' => 0,
'max' => '',
- 'step' => static function (Options $options) {
+ 'step' => static function (Options $options): int|string {
if (true === $options['is_integer']) {
return 1;
}
@@ -138,7 +135,7 @@ final class SIUnitType extends AbstractType implements DataMapperInterface
//Check if we need to make this thing small
if (isset($options['attr']['class'])) {
- $view->vars['sm'] = false !== strpos($options['attr']['class'], 'form-control-sm');
+ $view->vars['sm'] = str_contains((string) $options['attr']['class'], 'form-control-sm');
}
$view->vars['unit'] = $options['unit'];
@@ -146,17 +143,17 @@ final class SIUnitType extends AbstractType implements DataMapperInterface
}
/**
- * Maps the view data of a compound form to its children.
+ * Maps the view data of a compound form to its children.
*
- * The method is responsible for calling {@link FormInterface::setData()}
- * on the children of compound forms, defining their underlying model data.
+ * The method is responsible for calling {@link FormInterface::setData()}
+ * on the children of compound forms, defining their underlying model data.
*
* @param mixed $viewData View data of the compound form being initialized
- * @param FormInterface[]|Traversable $forms A list of {@link FormInterface} instances
+ * @param Traversable $forms A list of {@link FormInterface} instances
*
* @throws Exception\UnexpectedTypeException if the type of the data parameter is not supported
*/
- public function mapDataToForms($viewData, $forms): void
+ public function mapDataToForms(mixed $viewData, \Traversable $forms): void
{
$forms = iterator_to_array($forms);
@@ -179,35 +176,35 @@ final class SIUnitType extends AbstractType implements DataMapperInterface
}
/**
- * Maps the model data of a list of children forms into the view data of their parent.
+ * Maps the model data of a list of children forms into the view data of their parent.
*
- * This is the internal cascade call of FormInterface::submit for compound forms, since they
- * cannot be bound to any input nor the request as scalar, but their children may:
+ * This is the internal cascade call of FormInterface::submit for compound forms, since they
+ * cannot be bound to any input nor the request as scalar, but their children may:
*
- * $compoundForm->submit($arrayOfChildrenViewData)
- * // inside:
- * $childForm->submit($childViewData);
- * // for each entry, do the same and/or reverse transform
- * $this->dataMapper->mapFormsToData($compoundForm, $compoundInitialViewData)
- * // then reverse transform
+ * $compoundForm->submit($arrayOfChildrenViewData)
+ * // inside:
+ * $childForm->submit($childViewData);
+ * // for each entry, do the same and/or reverse transform
+ * $this->dataMapper->mapFormsToData($compoundForm, $compoundInitialViewData)
+ * // then reverse transform
*
- * When a simple form is submitted the following is happening:
+ * When a simple form is submitted the following is happening:
*
- * $simpleForm->submit($submittedViewData)
- * // inside:
- * $this->viewData = $submittedViewData
- * // then reverse transform
+ * $simpleForm->submit($submittedViewData)
+ * // inside:
+ * $this->viewData = $submittedViewData
+ * // then reverse transform
*
- * The model data can be an array or an object, so this second argument is always passed
- * by reference.
+ * The model data can be an array or an object, so this second argument is always passed
+ * by reference.
*
- * @param FormInterface[]|Traversable $forms A list of {@link FormInterface} instances
+ * @param Traversable $forms A list of {@link FormInterface} instances
* @param mixed $viewData The compound form's view data that get mapped
* its children model data
*
* @throws Exception\UnexpectedTypeException if the type of the data parameter is not supported
*/
- public function mapFormsToData($forms, &$viewData): void
+ public function mapFormsToData(\Traversable $forms, mixed &$viewData): void
{
//Convert both fields to a single float value.
diff --git a/src/Form/Type/StaticFileAutocompleteType.php b/src/Form/Type/StaticFileAutocompleteType.php
new file mode 100644
index 00000000..4d483e2a
--- /dev/null
+++ b/src/Form/Type/StaticFileAutocompleteType.php
@@ -0,0 +1,63 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Form\Type;
+
+use Symfony\Component\Asset\Packages;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\TextType;
+use Symfony\Component\Form\FormInterface;
+use Symfony\Component\Form\FormView;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+/**
+ * Implements a text type with autocomplete functionality based on a static file, containing a list of autocomplete
+ * suggestions.
+ * Other values are allowed, but the user can select from the list of suggestions.
+ * The file must be located in the public directory!
+ */
+class StaticFileAutocompleteType extends AbstractType
+{
+ public function __construct(
+ private readonly Packages $assets
+ ) {
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setRequired('file');
+ $resolver->setAllowedTypes('file', 'string');
+ }
+
+ public function getParent(): string
+ {
+ return TextType::class;
+ }
+
+ public function finishView(FormView $view, FormInterface $form, array $options): void
+ {
+ //Add the data-controller and data-url attributes to the form field
+ $view->vars['attr']['data-controller'] = 'elements--static-file-autocomplete';
+ $view->vars['attr']['data-url'] = $this->assets->getUrl($options['file']);
+ }
+}
\ No newline at end of file
diff --git a/src/Form/Type/StructuralEntityType.php b/src/Form/Type/StructuralEntityType.php
index 9c65a7ef..1018eeeb 100644
--- a/src/Form/Type/StructuralEntityType.php
+++ b/src/Form/Type/StructuralEntityType.php
@@ -22,28 +22,19 @@ declare(strict_types=1);
namespace App\Form\Type;
-use App\Entity\Attachments\AttachmentType;
-use App\Entity\Base\AbstractStructuralDBElement;
+use App\Entity\Base\AbstractNamedDBElement;
use App\Form\Type\Helper\StructuralEntityChoiceHelper;
use App\Form\Type\Helper\StructuralEntityChoiceLoader;
-use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Trees\NodesListBuilder;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
-use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader;
-use Symfony\Component\Form\Event\PostSubmitEvent;
use Symfony\Component\Form\Event\PreSubmitEvent;
-use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
-use Symfony\Component\Form\FormInterface;
-use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
-use Symfony\Component\Validator\Constraints\AtLeastOneOf;
-use Symfony\Component\Validator\Constraints\IsNull;
use Symfony\Component\Validator\Constraints\Valid;
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -53,31 +44,21 @@ use Symfony\Contracts\Translation\TranslatorInterface;
*/
class StructuralEntityType extends AbstractType
{
- protected EntityManagerInterface $em;
- protected TranslatorInterface $translator;
- protected StructuralEntityChoiceHelper $choice_helper;
-
- /**
- * @var NodesListBuilder
- */
- protected NodesListBuilder $builder;
-
- public function __construct(EntityManagerInterface $em, NodesListBuilder $builder, TranslatorInterface $translator, StructuralEntityChoiceHelper $choice_helper)
+ public function __construct(protected EntityManagerInterface $em, protected NodesListBuilder $builder, protected TranslatorInterface $translator, protected StructuralEntityChoiceHelper $choice_helper)
{
- $this->em = $em;
- $this->builder = $builder;
- $this->translator = $translator;
- $this->choice_helper = $choice_helper;
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event) {
- //When the data contains non-digit characters, we assume that the user entered a new element.
+ //When the data starts with "$%$", we assume that the user entered a new element.
//In that case we add the new element to our choice_loader
$data = $event->getData();
- if (null === $data || !is_string($data) || $data === "" || ctype_digit($data)) {
+ if (is_string($data) && str_starts_with($data, '$%$')) {
+ //Extract the real name from the data
+ $data = substr($data, 3);
+ } else {
return;
}
@@ -86,15 +67,12 @@ class StructuralEntityType extends AbstractType
$choice_loader = $options['choice_loader'];
if ($choice_loader instanceof StructuralEntityChoiceLoader) {
$choice_loader->setAdditionalElement($data);
+ $choice_loader->setForm($form);
}
});
$builder->addModelTransformer(new CallbackTransformer(
- function ($value) use ($options) {
- return $this->modelTransform($value, $options);
- }, function ($value) use ($options) {
- return $this->modelReverseTransform($value, $options);
- }));
+ fn($value) => $this->modelTransform($value, $options), fn($value) => $this->modelReverseTransform($value, $options)));
}
public function configureOptions(OptionsResolver $resolver): void
@@ -105,29 +83,15 @@ class StructuralEntityType extends AbstractType
'show_fullpath_in_subtext' => true, //When this is enabled, the full path will be shown in subtext
'subentities_of' => null, //Only show entities with the given parent class
'disable_not_selectable' => false, //Disable entries with not selectable property
- 'choice_value' => function (?AbstractStructuralDBElement $element) {
- return $this->choice_helper->generateChoiceValue($element);
- }, //Use the element id as option value and for comparing items
- 'choice_loader' => function (Options $options) {
- return new StructuralEntityChoiceLoader($options, $this->builder, $this->em);
- },
- 'choice_label' => function (Options $options) {
- return function ($choice, $key, $value) {
- return $this->choice_helper->generateChoiceLabel($choice);
- };
- },
- 'choice_attr' => function (Options $options) {
- return function ($choice, $key, $value) use ($options) {
- return $this->choice_helper->generateChoiceAttr($choice, $options);
- };
- },
- 'group_by' => function (AbstractStructuralDBElement $element) {
- return $this->choice_helper->generateGroupBy($element);
- },
+ 'choice_value' => fn(?AbstractNamedDBElement $element) => $this->choice_helper->generateChoiceValue($element), //Use the element id as option value and for comparing items
+ 'choice_loader' => fn(Options $options) => new StructuralEntityChoiceLoader($options, $this->builder, $this->em, $this->translator),
+ 'choice_label' => fn(Options $options) => fn($choice, $key, $value) => $this->choice_helper->generateChoiceLabel($choice),
+ 'choice_attr' => fn(Options $options) => fn($choice, $key, $value) => $this->choice_helper->generateChoiceAttr($choice, $options),
+ 'group_by' => fn(AbstractNamedDBElement $element) => $this->choice_helper->generateGroupBy($element),
'choice_translation_domain' => false, //Don't translate the entity names
]);
- //Set the constraints for the case that allow add is enabled (we then have to check that the new element is valid)
+ //Set the constraints for the case that allow to add is enabled (we then have to check that the new element is valid)
$resolver->setNormalizer('constraints', function (Options $options, $value) {
if ($options['allow_add']) {
$value[] = new Valid();
@@ -140,6 +104,13 @@ 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', fn(Options $options) => $this->dtoText($options['dto_value']));
+ $resolver->setDefault('help_html', fn(Options $options) => $options['dto_value'] !== null);
+
$resolver->setDefault('attr', function (Options $options) {
$tmp = [
'data-controller' => $options['controller'],
@@ -154,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
{
@@ -162,14 +143,19 @@ 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;
}
public function modelReverseTransform($value, array $options)
{
/* This step is important in combination with the caching!
- The elements deserialized from cache, are not known to Doctrinte ORM any more, so doctrine thinks,
- that the entity has changed (and so throws an exception about non-persited entities).
+ The elements deserialized from cache, are not known to Doctrine ORM anymore, so doctrine thinks,
+ that the entity has changed (and so throws an exception about non-persisted entities).
This function just retrieves a fresh copy of the entity from database, so doctrine detect correctly that no
change happened.
The performance impact of this should be very small in comparison of the boost, caused by the caching.
diff --git a/src/Form/Type/ThemeChoiceType.php b/src/Form/Type/ThemeChoiceType.php
index 88843903..7cdc0aa9 100644
--- a/src/Form/Type/ThemeChoiceType.php
+++ b/src/Form/Type/ThemeChoiceType.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Form\Type;
-use App\Entity\UserSystem\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ThemeChoiceType extends AbstractType
{
- private array $available_themes;
-
- public function __construct(array $available_themes)
+ public function __construct(private readonly array $available_themes)
{
- $this->available_themes = $available_themes;
}
- public function getParent()
+ public function getParent(): string
{
return ChoiceType::class;
}
- public function configureOptions(OptionsResolver $resolver)
+ public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'choices' => $this->available_themes,
- 'choice_label' => static function ($entity, $key, $value) {
- return $value;
- },
+ 'choice_label' => static fn($entity, $key, $value) => $value,
'choice_translation_domain' => false,
'placeholder' => 'user_settings.theme.placeholder'
]);
}
-}
\ No newline at end of file
+}
diff --git a/src/Form/Type/TriStateCheckboxType.php b/src/Form/Type/TriStateCheckboxType.php
index 6e8dafc4..4523a839 100644
--- a/src/Form/Type/TriStateCheckboxType.php
+++ b/src/Form/Type/TriStateCheckboxType.php
@@ -99,9 +99,8 @@ final class TriStateCheckboxType extends AbstractType implements DataTransformer
*
* @return mixed The value in the transformed representation
*
- * @throws TransformationFailedException when the transformation fails
*/
- public function transform($value)
+ public function transform(mixed $value)
{
if (true === $value) {
return 'true';
@@ -142,22 +141,14 @@ final class TriStateCheckboxType extends AbstractType implements DataTransformer
* @param mixed $value The value in the transformed representation
*
* @return mixed The value in the original representation
- *
- * @throws TransformationFailedException when the transformation fails
*/
- public function reverseTransform($value)
+ public function reverseTransform(mixed $value)
{
- switch ($value) {
- case 'true':
- return true;
- case 'false':
- return false;
- case 'indeterminate':
- case 'null':
- case '':
- return null;
- default:
- throw new InvalidArgumentException('Invalid value encountered!: '.$value);
- }
+ return match ($value) {
+ 'true' => true,
+ 'false' => false,
+ 'indeterminate', 'null', '' => null,
+ default => throw new InvalidArgumentException('Invalid value encountered!: '.$value),
+ };
}
}
diff --git a/src/Form/Type/UserSelectType.php b/src/Form/Type/UserSelectType.php
new file mode 100644
index 00000000..8862cdf7
--- /dev/null
+++ b/src/Form/Type/UserSelectType.php
@@ -0,0 +1,45 @@
+.
+ */
+namespace App\Form\Type;
+
+use App\Entity\UserSystem\User;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\OptionsResolver\Options;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+class UserSelectType extends AbstractType
+{
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'class' => User::class,
+ 'choice_label' => fn(Options $options) => static fn(User $choice, $key, $value) => $choice->getFullName(true),
+ ]);
+ }
+
+ public function getParent(): string
+ {
+ return StructuralEntityType::class;
+ }
+}
diff --git a/src/Form/UserAdminForm.php b/src/Form/UserAdminForm.php
index 774cf8f7..864bcf6b 100644
--- a/src/Form/UserAdminForm.php
+++ b/src/Form/UserAdminForm.php
@@ -22,18 +22,18 @@ declare(strict_types=1);
namespace App\Form;
+use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Base\AbstractNamedDBElement;
-use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use App\Form\Permissions\PermissionsType;
use App\Form\Type\CurrencyEntityType;
use App\Form\Type\MasterPictureAttachmentType;
+use App\Form\Type\RichTextEditorType;
use App\Form\Type\StructuralEntityType;
use App\Form\Type\ThemeChoiceType;
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\LanguageType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
@@ -44,16 +44,12 @@ use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TimezoneType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
-use Symfony\Component\Security\Core\Security;
use Symfony\Component\Validator\Constraints\Length;
class UserAdminForm extends AbstractType
{
- protected Security $security;
-
- public function __construct(Security $security)
+ public function __construct(protected Security $security)
{
- $this->security = $security;
}
public function configureOptions(OptionsResolver $resolver): void
@@ -61,11 +57,13 @@ class UserAdminForm extends AbstractType
parent::configureOptions($resolver); // TODO: Change the autogenerated stub
$resolver->setRequired('attachment_class');
$resolver->setDefault('parameter_class', false);
+
+ $resolver->setDefault('validation_groups', ['Default', 'permissions:edit']);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
- /** @var AbstractStructuralDBElement $entity */
+ /** @var User $entity */
$entity = $options['data'];
$is_new = null === $entity->getID();
@@ -116,7 +114,11 @@ class UserAdminForm extends AbstractType
'required' => false,
'disabled' => !$this->security->isGranted('edit_infos', $entity),
])
-
+ ->add('showEmailOnProfile', CheckboxType::class, [
+ 'required' => false,
+ 'label' => 'user.show_email_on_profile.label',
+ 'disabled' => !$this->security->isGranted('edit_infos', $entity),
+ ])
->add('department', TextType::class, [
'empty_data' => '',
'label' => 'user.department.label',
@@ -126,6 +128,16 @@ class UserAdminForm extends AbstractType
'required' => false,
'disabled' => !$this->security->isGranted('edit_infos', $entity),
])
+ ->add('aboutMe', RichTextEditorType::class, [
+ 'required' => false,
+ 'empty_data' => '',
+ 'label' => 'user.aboutMe.label',
+ 'attr' => [
+ 'rows' => 4,
+ ],
+ 'mode' => 'markdown-full',
+ 'disabled' => !$this->security->isGranted('edit_infos', $entity),
+ ])
//Config section
->add('language', LanguageType::class, [
@@ -157,6 +169,7 @@ class UserAdminForm extends AbstractType
'type' => PasswordType::class,
'first_options' => [
'label' => 'user.settings.pw_new.label',
+ 'password_estimator' => true,
],
'second_options' => [
'label' => 'user.settings.pw_confirm.label',
@@ -164,7 +177,7 @@ class UserAdminForm extends AbstractType
'invalid_message' => 'password_must_match',
'required' => false,
'mapped' => false,
- 'disabled' => !$this->security->isGranted('set_password', $entity),
+ 'disabled' => !$this->security->isGranted('set_password', $entity) || $entity->isSamlUser(),
'constraints' => [new Length([
'min' => 6,
'max' => 128,
@@ -174,7 +187,7 @@ class UserAdminForm extends AbstractType
->add('need_pw_change', CheckboxType::class, [
'required' => false,
'label' => 'user.edit.needs_pw_change',
- 'disabled' => !$this->security->isGranted('set_password', $entity),
+ 'disabled' => !$this->security->isGranted('set_password', $entity) || $entity->isSamlUser(),
])
->add('disabled', CheckboxType::class, [
diff --git a/src/Form/UserSettingsType.php b/src/Form/UserSettingsType.php
index fd07ea3b..05f63df4 100644
--- a/src/Form/UserSettingsType.php
+++ b/src/Form/UserSettingsType.php
@@ -22,12 +22,15 @@ declare(strict_types=1);
namespace App\Form;
+use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\UserSystem\User;
use App\Form\Type\CurrencyEntityType;
+use App\Form\Type\RichTextEditorType;
use App\Form\Type\ThemeChoiceType;
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PreSetDataEvent;
-use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
+use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\LanguageType;
@@ -38,18 +41,14 @@ use Symfony\Component\Form\Extension\Core\Type\TimezoneType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
-use Symfony\Component\Security\Core\Security;
use Symfony\Component\Validator\Constraints\File;
class UserSettingsType extends AbstractType
{
- protected Security $security;
- protected bool $demo_mode;
-
- public function __construct(Security $security, bool $demo_mode)
+ public function __construct(protected Security $security,
+ protected bool $demo_mode,
+ #[Autowire(param: 'partdb.locale_menu')] private readonly array $preferred_languages)
{
- $this->security = $security;
- $this->demo_mode = $demo_mode;
}
public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -57,7 +56,7 @@ class UserSettingsType extends AbstractType
$builder
->add('name', TextType::class, [
'label' => 'user.username.label',
- 'disabled' => !$this->security->isGranted('edit_username', $options['data']) || $this->demo_mode,
+ 'disabled' => !$this->security->isGranted('edit_username', $options['data']) || $this->demo_mode || $options['data']->isSamlUser(),
])
->add('first_name', TextType::class, [
'required' => false,
@@ -79,6 +78,11 @@ class UserSettingsType extends AbstractType
'label' => 'user.email.label',
'disabled' => !$this->security->isGranted('edit_infos', $options['data']) || $this->demo_mode,
])
+ ->add('showEmailOnProfile', CheckboxType::class, [
+ 'required' => false,
+ 'label' => 'user.show_email_on_profile.label',
+ 'disabled' => !$this->security->isGranted('edit_infos', $options['data']) || $this->demo_mode,
+ ])
->add('avatar_file', FileType::class, [
'label' => 'user_settings.change_avatar.label',
'mapped' => false,
@@ -89,16 +93,26 @@ class UserSettingsType extends AbstractType
],
'constraints' => [
new File([
- 'maxSize' => '2M',
+ 'maxSize' => '5M',
]),
],
])
+ ->add('aboutMe', RichTextEditorType::class, [
+ 'required' => false,
+ 'empty_data' => '',
+ 'label' => 'user.aboutMe.label',
+ 'attr' => [
+ 'rows' => 4,
+ ],
+ 'mode' => 'markdown-full',
+ 'disabled' => !$this->security->isGranted('edit_infos', $options['data']) || $this->demo_mode,
+ ])
->add('language', LanguageType::class, [
'disabled' => $this->demo_mode,
'required' => false,
'placeholder' => 'user_settings.language.placeholder',
'label' => 'user.language_select',
- 'preferred_choices' => ['en', 'de'],
+ 'preferred_choices' => $this->preferred_languages,
])
->add('timezone', TimezoneType::class, [
'disabled' => $this->demo_mode,
diff --git a/src/Helpers/BBCodeToMarkdownConverter.php b/src/Helpers/BBCodeToMarkdownConverter.php
index 9ee4dff1..922e6a7e 100644
--- a/src/Helpers/BBCodeToMarkdownConverter.php
+++ b/src/Helpers/BBCodeToMarkdownConverter.php
@@ -24,8 +24,10 @@ namespace App\Helpers;
use League\HTMLToMarkdown\HtmlConverter;
use s9e\TextFormatter\Bundles\Forum as TextFormatter;
-use SebastianBergmann\CodeCoverage\Report\Text;
+/**
+ * @see \App\Tests\Helpers\BBCodeToMarkdownConverterTest
+ */
class BBCodeToMarkdownConverter
{
protected HtmlConverter $html_to_markdown;
diff --git a/src/Helpers/FilenameSanatizer.php b/src/Helpers/FilenameSanatizer.php
new file mode 100644
index 00000000..f6744b1a
--- /dev/null
+++ b/src/Helpers/FilenameSanatizer.php
@@ -0,0 +1,58 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Helpers;
+
+/**
+ * This class provides functions to sanitize filenames.
+ */
+class FilenameSanatizer
+{
+ /**
+ * Converts a given filename to a version, which is guaranteed to be safe to use on all filesystems.
+ * This function is adapted from https://stackoverflow.com/a/42058764/21879970
+ * @param string $filename
+ * @return string
+ */
+ public static function sanitizeFilename(string $filename): string
+ {
+ //Convert to ASCII
+ $filename = iconv('UTF-8', 'ASCII//TRANSLIT', $filename);
+
+ $filename = preg_replace(
+ '~
+ [<>:"/\\\|?*]| # file system reserved https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words
+ [\x00-\x1F]| # control characters http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247%28v=vs.85%29.aspx
+ [\x7F\xA0\xAD]| # non-printing characters DEL, NO-BREAK SPACE, SOFT HYPHEN
+ [#\[\]@!$&\'()+,;=]| # URI reserved https://www.rfc-editor.org/rfc/rfc3986#section-2.2
+ [{}^\~`] # URL unsafe characters https://www.ietf.org/rfc/rfc1738.txt
+ ~x',
+ '-', $filename);
+
+ // avoids ".", ".." or ".hiddenFiles"
+ $filename = ltrim((string) $filename, '.-');
+ //Limit filename length to 255 bytes
+ $ext = pathinfo($filename, PATHINFO_EXTENSION);
+ return mb_strcut(pathinfo($filename, PATHINFO_FILENAME), 0, 255 - ($ext !== '' && $ext !== '0' ? strlen($ext) + 1 : 0), mb_detect_encoding($filename)) . ($ext !== '' && $ext !== '0' ? '.' . $ext : '');
+ }
+}
\ No newline at end of file
diff --git a/src/Helpers/IPAnonymizer.php b/src/Helpers/IPAnonymizer.php
new file mode 100644
index 00000000..9662852f
--- /dev/null
+++ b/src/Helpers/IPAnonymizer.php
@@ -0,0 +1,49 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Helpers;
+
+use Symfony\Component\HttpFoundation\IpUtils;
+
+/**
+ * Utils to assist with IP anonymization.
+ * The IPUtils::anonymize has a certain edgecase with local-link addresses, which is handled here.
+ * See: https://github.com/Part-DB/Part-DB-server/issues/782
+ */
+final class IPAnonymizer
+{
+ public static function anonymize(string $ip): string
+ {
+ /**
+ * If the IP contains a % symbol, then it is a local-link address with scoping according to RFC 4007
+ * In that case, we only care about the part before the % symbol, as the following functions, can only work with
+ * the IP address itself. As the scope can leak information (containing interface name), we do not want to
+ * include it in our anonymized IP data.
+ */
+ if (str_contains($ip, '%')) {
+ $ip = substr($ip, 0, strpos($ip, '%'));
+ }
+
+ return IpUtils::anonymize($ip);
+ }
+}
\ No newline at end of file
diff --git a/src/Helpers/LabelResponse.php b/src/Helpers/LabelResponse.php
index 1dbb947b..2973eb7e 100644
--- a/src/Helpers/LabelResponse.php
+++ b/src/Helpers/LabelResponse.php
@@ -54,7 +54,7 @@ class LabelResponse extends Response
parent::__construct($content, $status, $headers);
}
- public function setContent($content): self
+ public function setContent($content): static
{
parent::setContent($content);
@@ -64,7 +64,7 @@ class LabelResponse extends Response
return $this;
}
- public function prepare(Request $request): self
+ public function prepare(Request $request): static
{
parent::prepare($request);
@@ -84,7 +84,7 @@ class LabelResponse extends Response
*/
public function setAutoLastModified(): LabelResponse
{
- $this->setLastModified(new DateTime());
+ $this->setLastModified(new \DateTimeImmutable());
return $this;
}
@@ -110,7 +110,7 @@ class LabelResponse extends Response
*/
public function setContentDisposition(string $disposition, string $filename, string $filenameFallback = ''): self
{
- if ('' === $filenameFallback && (!preg_match('/^[\x20-\x7e]*$/', $filename) || false !== strpos($filename, '%'))) {
+ if ('' === $filenameFallback && (!preg_match('/^[\x20-\x7e]*$/', $filename) || str_contains($filename, '%'))) {
$encoding = mb_detect_encoding($filename, null, true) ?: '8bit';
for ($i = 0, $filenameLength = mb_strlen($filename, $encoding); $i < $filenameLength; ++$i) {
diff --git a/src/Helpers/Projects/ProjectBuildRequest.php b/src/Helpers/Projects/ProjectBuildRequest.php
index 093581f4..430d37b5 100644
--- a/src/Helpers/Projects/ProjectBuildRequest.php
+++ b/src/Helpers/Projects/ProjectBuildRequest.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Helpers\Projects;
+use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Validator\Constraints\ProjectSystem\ValidProjectBuildRequest;
/**
- * @ValidProjectBuildRequest()
+ * @see \App\Tests\Helpers\Projects\ProjectBuildRequestTest
*/
+#[ValidProjectBuildRequest]
final class ProjectBuildRequest
{
- private Project $project;
- private int $number_of_builds;
+ private readonly int $number_of_builds;
/**
* @var array
@@ -44,23 +47,23 @@ final class ProjectBuildRequest
private bool $add_build_to_builds_part = false;
+ private bool $dont_check_quantity = false;
+
/**
* @param Project $project The project that should be build
* @param int $number_of_builds The number of builds that should be created
*/
- public function __construct(Project $project, int $number_of_builds)
+ public function __construct(private readonly Project $project, int $number_of_builds)
{
if ($number_of_builds < 1) {
throw new \InvalidArgumentException('Number of builds must be at least 1!');
}
-
- $this->project = $project;
$this->number_of_builds = $number_of_builds;
$this->initializeArray();
//By default, use the first available lot of builds part if there is one.
- if($project->getBuildPart() !== null) {
+ if($project->getBuildPart() instanceof Part) {
$this->add_build_to_builds_part = true;
foreach( $project->getBuildPart()->getPartLots() as $lot) {
if (!$lot->isInstockUnknown()) {
@@ -89,8 +92,6 @@ final class ProjectBuildRequest
/**
* Ensure that the projectBOMEntry belongs to the project, otherwise throw an exception.
- * @param ProjectBOMEntry $entry
- * @return void
*/
private function ensureBOMEntryValid(ProjectBOMEntry $entry): void
{
@@ -101,7 +102,6 @@ final class ProjectBuildRequest
/**
* Returns the partlot where the builds should be added to, or null if it should not be added to any lot.
- * @return PartLot|null
*/
public function getBuildsPartLot(): ?PartLot
{
@@ -110,7 +110,6 @@ final class ProjectBuildRequest
/**
* Return if the builds should be added to the builds part of this project as new stock
- * @return bool
*/
public function getAddBuildsToBuildsPart(): bool
{
@@ -119,7 +118,6 @@ final class ProjectBuildRequest
/**
* Set if the builds should be added to the builds part of this project as new stock
- * @param bool $new_value
* @return $this
*/
public function setAddBuildsToBuildsPart(bool $new_value): self
@@ -136,17 +134,16 @@ final class ProjectBuildRequest
/**
* Set the partlot where the builds should be added to, or null if it should not be added to any lot.
* The part lot must belong to the project build part, or an exception is thrown!
- * @param PartLot|null $new_part_lot
* @return $this
*/
public function setBuildsPartLot(?PartLot $new_part_lot): self
{
//Ensure that this new_part_lot belongs to the project
- if (($new_part_lot !== null && $new_part_lot->getPart() !== $this->project->getBuildPart()) || $this->project->getBuildPart() === null) {
+ if (($new_part_lot instanceof PartLot && $new_part_lot->getPart() !== $this->project->getBuildPart()) || !$this->project->getBuildPart() instanceof Part) {
throw new \InvalidArgumentException('The given part lot does not belong to the projects build part!');
}
- if ($new_part_lot !== null) {
+ if ($new_part_lot instanceof PartLot) {
$this->setAddBuildsToBuildsPart(true);
}
@@ -157,7 +154,6 @@ final class ProjectBuildRequest
/**
* Returns the comment where the user can write additional information about the build.
- * @return string
*/
public function getComment(): string
{
@@ -166,7 +162,6 @@ final class ProjectBuildRequest
/**
* Sets the comment where the user can write additional information about the build.
- * @param string $comment
*/
public function setComment(string $comment): void
{
@@ -175,18 +170,11 @@ final class ProjectBuildRequest
/**
* Returns the amount of parts that should be withdrawn from the given lot for the corresponding BOM entry.
- * @param PartLot|int $lot The part lot (or the ID of the part lot) for which the withdraw amount should be get
- * @return float
+ * @param PartLot|int $lot The part lot (or the ID of the part lot) for which the withdrawal amount should be got
*/
- public function getLotWithdrawAmount($lot): float
+ public function getLotWithdrawAmount(PartLot|int $lot): float
{
- if ($lot instanceof PartLot) {
- $lot_id = $lot->getID();
- } elseif (is_int($lot)) {
- $lot_id = $lot;
- } else {
- throw new \InvalidArgumentException('The given lot must be an instance of PartLot or an ID of a PartLot!');
- }
+ $lot_id = $lot instanceof PartLot ? $lot->getID() : $lot;
if (! array_key_exists($lot_id, $this->withdraw_amounts)) {
throw new \InvalidArgumentException('The given lot is not in the withdraw amounts array!');
@@ -197,11 +185,10 @@ final class ProjectBuildRequest
/**
* Sets the amount of parts that should be withdrawn from the given lot for the corresponding BOM entry.
- * @param PartLot|int $lot The part lot (or the ID of the part lot) for which the withdraw amount should be get
- * @param float $amount
+ * @param PartLot|int $lot The part lot (or the ID of the part lot) for which the withdrawal amount should be got
* @return $this
*/
- public function setLotWithdrawAmount($lot, float $amount): self
+ public function setLotWithdrawAmount(PartLot|int $lot, float $amount): self
{
if ($lot instanceof PartLot) {
$lot_id = $lot->getID();
@@ -218,8 +205,6 @@ final class ProjectBuildRequest
/**
* Returns the sum of all withdraw amounts for the given BOM entry.
- * @param ProjectBOMEntry $entry
- * @return float
*/
public function getWithdrawAmountSum(ProjectBOMEntry $entry): float
{
@@ -239,14 +224,13 @@ final class ProjectBuildRequest
/**
* Returns the number of available lots to take stock from for the given BOM entry.
- * @param ProjectBOMEntry $projectBOMEntry
* @return PartLot[]|null Returns null if the entry is a non-part BOM entry
*/
public function getPartLotsForBOMEntry(ProjectBOMEntry $projectBOMEntry): ?array
{
$this->ensureBOMEntryValid($projectBOMEntry);
- if ($projectBOMEntry->getPart() === null) {
+ if (!$projectBOMEntry->getPart() instanceof Part) {
return null;
}
@@ -256,8 +240,6 @@ final class ProjectBuildRequest
/**
* Returns the needed amount of parts for the given BOM entry.
- * @param ProjectBOMEntry $entry
- * @return float
*/
public function getNeededAmountForBOMEntry(ProjectBOMEntry $entry): float
{
@@ -267,7 +249,7 @@ final class ProjectBuildRequest
}
/**
- * Returns the list of all bom entries that have to be build.
+ * Returns the list of all bom entries that have to be built.
* @return ProjectBOMEntry[]
*/
public function getBomEntries(): array
@@ -276,19 +258,16 @@ final class ProjectBuildRequest
}
/**
- * Returns the all part bom entries that have to be build.
+ * Returns the all part bom entries that have to be built.
* @return ProjectBOMEntry[]
*/
public function getPartBomEntries(): array
{
- return $this->project->getBomEntries()->filter(function (ProjectBOMEntry $entry) {
- return $entry->isPartBomEntry();
- })->toArray();
+ return $this->project->getBomEntries()->filter(fn(ProjectBOMEntry $entry) => $entry->isPartBomEntry())->toArray();
}
/**
* Returns which project should be build
- * @return Project
*/
public function getProject(): Project
{
@@ -297,10 +276,31 @@ final class ProjectBuildRequest
/**
* Returns the number of builds that should be created.
- * @return int
*/
public function getNumberOfBuilds(): int
{
return $this->number_of_builds;
}
-}
\ No newline at end of file
+
+ /**
+ * If Set to true, the given withdraw amounts are used without any checks for requirements.
+ * @return bool
+ */
+ public function isDontCheckQuantity(): bool
+ {
+ return $this->dont_check_quantity;
+ }
+
+ /**
+ * Set to true, the given withdraw amounts are used without any checks for requirements.
+ * @param bool $dont_check_quantity
+ * @return $this
+ */
+ public function setDontCheckQuantity(bool $dont_check_quantity): ProjectBuildRequest
+ {
+ $this->dont_check_quantity = $dont_check_quantity;
+ return $this;
+ }
+
+
+}
diff --git a/src/Helpers/Trees/TreeViewNode.php b/src/Helpers/Trees/TreeViewNode.php
index 89e4a02e..0c5fcdce 100644
--- a/src/Helpers/Trees/TreeViewNode.php
+++ b/src/Helpers/Trees/TreeViewNode.php
@@ -30,17 +30,13 @@ use JsonSerializable;
*/
final class TreeViewNode implements JsonSerializable
{
- private $text;
- private $href;
- private $nodes;
+ private ?TreeViewNodeState $state = null;
- private $state = null;
+ private ?array $tags = null;
- private $tags;
+ private ?int $id = null;
- private $id;
-
- private $icon;
+ private ?string $icon = null;
/**
* Creates a new TreeView node with the given parameters.
@@ -51,12 +47,8 @@ final class TreeViewNode implements JsonSerializable
* @param array|null $nodes An array containing other TreeViewNodes. They will be used as children nodes of the
* newly created nodes. Set to null, if it should not have children.
*/
- public function __construct(string $text, ?string $href = null, ?array $nodes = null)
+ public function __construct(private string $text, private ?string $href = null, private ?array $nodes = null)
{
- $this->text = $text;
- $this->href = $href;
- $this->nodes = $nodes;
-
//$this->state = new TreeViewNodeState();
}
@@ -94,8 +86,6 @@ final class TreeViewNode implements JsonSerializable
* Sets the node text.
*
* @param string $text the new node text
- *
- * @return TreeViewNode
*/
public function setText(string $text): self
{
@@ -116,8 +106,6 @@ final class TreeViewNode implements JsonSerializable
* Sets the href link.
*
* @param string|null $href the new href link
- *
- * @return TreeViewNode
*/
public function setHref(?string $href): self
{
@@ -140,8 +128,6 @@ final class TreeViewNode implements JsonSerializable
* Sets the children nodes.
*
* @param array|null $nodes The new children nodes
- *
- * @return TreeViewNode
*/
public function setNodes(?array $nodes): self
{
@@ -165,7 +151,7 @@ final class TreeViewNode implements JsonSerializable
public function setDisabled(?bool $disabled): self
{
//Lazy loading of state, so it does not need to get serialized and transfered, when it is empty.
- if (null === $this->state) {
+ if (!$this->state instanceof TreeViewNodeState) {
$this->state = new TreeViewNodeState();
}
@@ -177,7 +163,7 @@ final class TreeViewNode implements JsonSerializable
public function setSelected(?bool $selected): self
{
//Lazy loading of state, so it does not need to get serialized and transfered, when it is empty.
- if (null === $this->state) {
+ if (!$this->state instanceof TreeViewNodeState) {
$this->state = new TreeViewNodeState();
}
@@ -189,7 +175,7 @@ final class TreeViewNode implements JsonSerializable
public function setExpanded(?bool $selected = true): self
{
//Lazy loading of state, so it does not need to get serialized and transfered, when it is empty.
- if (null === $this->state) {
+ if (!$this->state instanceof TreeViewNodeState) {
$this->state = new TreeViewNodeState();
}
@@ -215,17 +201,11 @@ final class TreeViewNode implements JsonSerializable
return $this;
}
- /**
- * @return string|null
- */
public function getIcon(): ?string
{
return $this->icon;
}
- /**
- * @param string|null $icon
- */
public function setIcon(?string $icon): self
{
$this->icon = $icon;
@@ -252,7 +232,7 @@ final class TreeViewNode implements JsonSerializable
$ret['nodes'] = $this->nodes;
}
- if (null !== $this->state) {
+ if ($this->state instanceof TreeViewNodeState) {
$ret['state'] = $this->state;
}
diff --git a/src/Helpers/Trees/TreeViewNodeIterator.php b/src/Helpers/Trees/TreeViewNodeIterator.php
index 073218c0..ab8b4907 100644
--- a/src/Helpers/Trees/TreeViewNodeIterator.php
+++ b/src/Helpers/Trees/TreeViewNodeIterator.php
@@ -40,7 +40,7 @@ final class TreeViewNodeIterator extends ArrayIterator implements RecursiveItera
/** @var TreeViewNode $element */
$element = $this->current();
- return !empty($element->getNodes());
+ return $element->getNodes() !== null && $element->getNodes() !== [];
}
public function getChildren(): TreeViewNodeIterator
diff --git a/src/Helpers/Trees/TreeViewNodeState.php b/src/Helpers/Trees/TreeViewNodeState.php
index 92b4611c..727135bc 100644
--- a/src/Helpers/Trees/TreeViewNodeState.php
+++ b/src/Helpers/Trees/TreeViewNodeState.php
@@ -29,17 +29,17 @@ final class TreeViewNodeState implements JsonSerializable
/**
* @var bool|null
*/
- private $disabled = null;
+ private ?bool $disabled = null;
/**
* @var bool|null
*/
- private $expanded = null;
+ private ?bool $expanded = null;
/**
* @var bool|null
*/
- private $selected = null;
+ private ?bool $selected = null;
public function getDisabled(): ?bool
{
diff --git a/src/Helpers/TrinaryLogicHelper.php b/src/Helpers/TrinaryLogicHelper.php
new file mode 100644
index 00000000..f4b460de
--- /dev/null
+++ b/src/Helpers/TrinaryLogicHelper.php
@@ -0,0 +1,107 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Helpers;
+
+/**
+ * Helper functions for logic operations with trinary logic.
+ * True and false are represented as classical boolean values, undefined is represented as null.
+ * @see \App\Tests\Helpers\TrinaryLogicHelperTest
+ */
+class TrinaryLogicHelper
+{
+
+ /**
+ * Implements the trinary logic NOT.
+ * @param bool|null $a
+ * @return bool|null
+ */
+ public static function not(?bool $a): ?bool
+ {
+ if ($a === null) {
+ return null;
+ }
+ return !$a;
+ }
+
+
+ /**
+ * Returns the trinary logic OR of the given parameters. At least one parameter is required.
+ * @param bool|null ...$args
+ * @return bool|null
+ */
+ public static function or(?bool ...$args): ?bool
+ {
+ if (count($args) === 0) {
+ throw new \LogicException('At least one parameter is required.');
+ }
+
+ // The trinary or is the maximum of the integer representation of the parameters.
+ return self::intToBool(
+ max(array_map(self::boolToInt(...), $args))
+ );
+ }
+
+ /**
+ * Returns the trinary logic AND of the given parameters. At least one parameter is required.
+ * @param bool|null ...$args
+ * @return bool|null
+ */
+ public static function and(?bool ...$args): ?bool
+ {
+ if (count($args) === 0) {
+ throw new \LogicException('At least one parameter is required.');
+ }
+
+ // The trinary and is the minimum of the integer representation of the parameters.
+ return self::intToBool(
+ min(array_map(self::boolToInt(...), $args))
+ );
+ }
+
+ /**
+ * Convert the trinary bool to an integer, where true is 1, false is -1 and null is 0.
+ * @param bool|null $a
+ * @return int
+ */
+ private static function boolToInt(?bool $a): int
+ {
+ if ($a === null) {
+ return 0;
+ }
+ return $a ? 1 : -1;
+ }
+
+ /**
+ * Convert the integer to a trinary bool, where 1 is true, -1 is false and 0 is null.
+ * @param int $a
+ * @return bool|null
+ */
+ private static function intToBool(int $a): ?bool
+ {
+ if ($a === 0) {
+ return null;
+ }
+ return $a > 0;
+ }
+}
\ No newline at end of file
diff --git a/src/Kernel.php b/src/Kernel.php
index a406b6c8..97c7a69e 100644
--- a/src/Kernel.php
+++ b/src/Kernel.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
diff --git a/src/Migration/AbstractMultiPlatformMigration.php b/src/Migration/AbstractMultiPlatformMigration.php
index 12d4cf87..bc2b3f19 100644
--- a/src/Migration/AbstractMultiPlatformMigration.php
+++ b/src/Migration/AbstractMultiPlatformMigration.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Migration;
use Doctrine\DBAL\Connection;
-use Doctrine\DBAL\DBALException;
-use Doctrine\DBAL\Driver\AbstractMySQLDriver;
-use Doctrine\DBAL\Driver\AbstractSQLiteDriver;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
-use Doctrine\DBAL\Platforms\MariaDBPlatform;
-use Doctrine\DBAL\Platforms\MySQLPlatform;
-use Doctrine\DBAL\Platforms\SqlitePlatform;
+use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
+use Doctrine\DBAL\Platforms\SQLitePlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Psr\Log\LoggerInterface;
abstract class AbstractMultiPlatformMigration extends AbstractMigration
{
- public const ADMIN_PW_LENGTH = 10;
-
- protected bool $permissions_updated = false;
+ final public const ADMIN_PW_LENGTH = 10;
protected string $admin_pw = '';
- protected LoggerInterface $logger;
-
- public function __construct(Connection $connection, LoggerInterface $logger)
+ /** @noinspection SenselessProxyMethodInspection
+ * This method is required to redefine the logger type hint to protected
+ */
+ public function __construct(Connection $connection, protected LoggerInterface $logger)
{
- $this->logger = $logger;
- AbstractMigration::__construct($connection, $logger);
+ parent::__construct($connection, $logger);
}
public function up(Schema $schema): void
{
$db_type = $this->getDatabaseType();
- switch ($db_type) {
- case 'mysql':
- $this->mySQLUp($schema);
- break;
- case 'sqlite':
- $this->sqLiteUp($schema);
- break;
- default:
- $this->abortIf(true, "Database type '$db_type' is not supported!");
- break;
- }
+ match ($db_type) {
+ 'mysql' => $this->mySQLUp($schema),
+ 'sqlite' => $this->sqLiteUp($schema),
+ 'postgresql' => $this->postgreSQLUp($schema),
+ default => $this->abortIf(true, "Database type '$db_type' is not supported!"),
+ };
}
public function down(Schema $schema): void
{
$db_type = $this->getDatabaseType();
- switch ($db_type) {
- case 'mysql':
- $this->mySQLDown($schema);
- break;
- case 'sqlite':
- $this->sqLiteDown($schema);
- break;
- default:
- $this->abortIf(true, "Database type is not supported!");
- break;
- }
+ match ($db_type) {
+ 'mysql' => $this->mySQLDown($schema),
+ 'sqlite' => $this->sqLiteDown($schema),
+ 'postgresql' => $this->postgreSQLDown($schema),
+ default => $this->abortIf(true, "Database type is not supported!"),
+ };
}
/**
- * Gets the legacy Part-DB version number. Returns 0, if target database is not an legacy Part-DB database.
+ * Gets the legacy Part-DB version number. Returns 0, if target database is not a legacy Part-DB database.
*/
public function getOldDBVersion(): int
{
@@ -98,7 +84,7 @@ abstract class AbstractMultiPlatformMigration extends AbstractMigration
return 0;
}
return (int) $version;
- } catch (Exception $dBALException) {
+ } catch (Exception) {
//when the table was not found, we can proceed, because we have an empty DB!
return 0;
}
@@ -110,7 +96,7 @@ abstract class AbstractMultiPlatformMigration extends AbstractMigration
*/
public function getInitalAdminPW(): string
{
- if (empty($this->admin_pw)) {
+ if ($this->admin_pw === '') {
if (!empty($_ENV['INITIAL_ADMIN_PW'])) {
$this->admin_pw = $_ENV['INITIAL_ADMIN_PW'];
} else {
@@ -118,29 +104,58 @@ abstract class AbstractMultiPlatformMigration extends AbstractMigration
}
}
- //As we dont have access to container, just use the default PHP pw hash function
- return password_hash($this->admin_pw, PASSWORD_DEFAULT);
- }
-
- public function printPermissionUpdateMessage(): void
- {
- $this->permissions_updated = true;
+ //As we don't have access to container, just use the default PHP pw hash function
+ return password_hash((string) $this->admin_pw, PASSWORD_DEFAULT);
}
public function postUp(Schema $schema): void
{
parent::postUp($schema);
- if($this->permissions_updated) {
- $this->logger->warning('[!!!] Permissions were updated! Please check if they fit your expectations!');
- }
- if (!empty($this->admin_pw)) {
+ if ($this->admin_pw !== '') {
$this->logger->warning('');
$this->logger->warning('The initial password for the "admin" user is: '.$this->admin_pw.'>');
$this->logger->warning('');
}
}
+ /**
+ * Checks if a foreign key on a table exists in the database.
+ * This method is only supported for MySQL/MariaDB databases yet!
+ * @return bool Returns true, if the foreign key exists
+ * @throws Exception
+ */
+ public function doesFKExists(string $table, string $fk_name): bool
+ {
+ $db_type = $this->getDatabaseType();
+ if ($db_type !== 'mysql') {
+ throw new \RuntimeException('This method is only supported for MySQL/MariaDB databases!');
+ }
+
+ $sql = "SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = '$fk_name' AND TABLE_NAME = '$table' AND CONSTRAINT_TYPE = 'FOREIGN KEY'";
+ $result = (int) $this->connection->fetchOne($sql);
+
+ return $result > 0;
+ }
+
+ /**
+ * Checks if a column exists in a table.
+ * @return bool Returns true, if the column exists
+ * @throws Exception
+ */
+ public function doesColumnExist(string $table, string $column_name): bool
+ {
+ $db_type = $this->getDatabaseType();
+ if ($db_type !== 'mysql') {
+ throw new \RuntimeException('This method is only supported for MySQL/MariaDB databases!');
+ }
+
+ $sql = "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '$table' AND COLUMN_NAME = '$column_name'";
+ $result = (int) $this->connection->fetchOne($sql);
+
+ return $result > 0;
+ }
+
/**
* Returns the database type of the used database.
* @return string|null Returns 'mysql' for MySQL/MariaDB and 'sqlite' for SQLite. Returns null if unknown type
@@ -151,10 +166,14 @@ abstract class AbstractMultiPlatformMigration extends AbstractMigration
return 'mysql';
}
- if ($this->connection->getDatabasePlatform() instanceof SqlitePlatform) {
+ if ($this->connection->getDatabasePlatform() instanceof SQLitePlatform) {
return 'sqlite';
}
+ if ($this->connection->getDatabasePlatform() instanceof PostgreSqlPlatform) {
+ return 'postgresql';
+ }
+
return null;
}
@@ -165,4 +184,8 @@ abstract class AbstractMultiPlatformMigration extends AbstractMigration
abstract public function sqLiteUp(Schema $schema): void;
abstract public function sqLiteDown(Schema $schema): void;
+
+ abstract public function postgreSQLUp(Schema $schema): void;
+
+ abstract public function postgreSQLDown(Schema $schema): void;
}
diff --git a/src/Migration/WithPermPresetsTrait.php b/src/Migration/WithPermPresetsTrait.php
new file mode 100644
index 00000000..44bc4510
--- /dev/null
+++ b/src/Migration/WithPermPresetsTrait.php
@@ -0,0 +1,72 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Migration;
+
+use App\Entity\UserSystem\PermissionData;
+use App\Security\Interfaces\HasPermissionsInterface;
+use App\Services\UserSystem\PermissionPresetsHelper;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+trait WithPermPresetsTrait
+{
+ private ?ContainerInterface $container = null;
+ private ?PermissionPresetsHelper $permission_presets_helper = null;
+
+ private function getJSONPermDataFromPreset(string $preset): string
+ {
+ if ($this->permission_presets_helper === null) {
+ throw new \RuntimeException('PermissionPresetsHelper not set! There seems to be some issue with the dependency injection!');
+ }
+
+ //Create a virtual user on which we can apply the preset
+ $user = new class implements HasPermissionsInterface {
+
+ public PermissionData $perm_data;
+
+ public function __construct()
+ {
+ $this->perm_data = new PermissionData();
+ }
+
+ public function getPermissions(): PermissionData
+ {
+ return $this->perm_data;
+ }
+ };
+
+ //Apply the preset to the virtual user
+ $this->permission_presets_helper->applyPreset($user, $preset);
+
+ //And return the json data
+ return json_encode($user->getPermissions());
+ }
+
+ public function setContainer(?ContainerInterface $container = null): void
+ {
+ if ($container !== null) {
+ $this->container = $container;
+ $this->permission_presets_helper = $container->get(PermissionPresetsHelper::class);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Repository/AbstractPartsContainingRepository.php b/src/Repository/AbstractPartsContainingRepository.php
index eab2c26c..4fd0bbff 100644
--- a/src/Repository/AbstractPartsContainingRepository.php
+++ b/src/Repository/AbstractPartsContainingRepository.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Repository;
use App\Entity\Base\AbstractPartsContainingDBElement;
@@ -25,17 +27,24 @@ use App\Entity\Base\PartsContainingRepositoryInterface;
use App\Entity\Parts\Part;
use InvalidArgumentException;
+/**
+ * @template TEntityClass of AbstractPartsContainingDBElement
+ * @extends StructuralDBElementRepository
+ */
abstract class AbstractPartsContainingRepository extends StructuralDBElementRepository implements PartsContainingRepositoryInterface
{
+ /** @var int The maximum number of levels for which we can recurse before throwing an error */
+ private const RECURSION_LIMIT = 50;
+
/**
* Returns all parts associated with this element.
*
* @param object $element the element for which the parts should be determined
- * @param array $order_by The order of the parts. Format ['name' => 'ASC']
+ * @param string $nameOrderDirection the direction in which the parts should be ordered by name, either ASC or DESC
*
* @return Part[]
*/
- abstract public function getParts(object $element, array $order_by = ['name' => 'ASC']): array;
+ abstract public function getParts(object $element, string $nameOrderDirection = "ASC"): array;
/**
* Gets the count of the parts associated with this element.
@@ -48,21 +57,63 @@ abstract class AbstractPartsContainingRepository extends StructuralDBElementRepo
/**
* Returns the count of the parts associated with this element and all its children.
* Please be aware that this function is pretty slow on large trees!
- * @param AbstractPartsContainingDBElement $element
* @return int
*/
public function getPartsCountRecursive(AbstractPartsContainingDBElement $element): int
{
+ return $this->getPartsCountRecursiveWithDepthN($element, self::RECURSION_LIMIT);
+ }
+
+ public function getPartsRecursive(AbstractPartsContainingDBElement $element): array
+ {
+ return $this->getPartsRecursiveWithDepthN($element, self::RECURSION_LIMIT);
+ }
+
+ /**
+ * The implementation of the recursive function to get the parts count.
+ * This function is used to limit the recursion depth (remaining_depth is decreased on each call).
+ * If the recursion limit is reached (remaining_depth <= 0), a RuntimeException is thrown.
+ * @internal This function is not intended to be called directly, use getPartsCountRecursive() instead.
+ * @return int
+ */
+ protected function getPartsCountRecursiveWithDepthN(AbstractPartsContainingDBElement $element, int $remaining_depth): int
+ {
+ if ($remaining_depth <= 0) {
+ throw new \RuntimeException('Recursion limit reached!');
+ }
+
$count = $this->getPartsCount($element);
+ //If the element is its own parent, we have a loop in the tree, so we stop here.
+ if ($element->getParent() === $element) {
+ return 0;
+ }
+
foreach ($element->getChildren() as $child) {
- $count += $this->getPartsCountRecursive($child);
+ $count += $this->getPartsCountRecursiveWithDepthN($child, $remaining_depth - 1);
}
return $count;
}
- protected function getPartsByField(object $element, array $order_by, string $field_name): array
+ protected function getPartsRecursiveWithDepthN(AbstractPartsContainingDBElement $element, int $remaining_depth): array
+ {
+ if ($remaining_depth <= 0) {
+ throw new \RuntimeException('Recursion limit reached!');
+ }
+
+ //Add direct parts
+ $parts = $this->getParts($element);
+
+ //Then iterate over all children and add their parts
+ foreach ($element->getChildren() as $child) {
+ $parts = array_merge($parts, $this->getPartsRecursiveWithDepthN($child, $remaining_depth - 1));
+ }
+
+ return $parts;
+ }
+
+ protected function getPartsByField(object $element, string $nameOrderDirection, string $field_name): array
{
if (!$element instanceof AbstractPartsContainingDBElement) {
throw new InvalidArgumentException('$element must be an instance of AbstractPartContainingDBElement!');
@@ -70,7 +121,14 @@ abstract class AbstractPartsContainingRepository extends StructuralDBElementRepo
$repo = $this->getEntityManager()->getRepository(Part::class);
- return $repo->findBy([$field_name => $element], $order_by);
+ //Build a query builder to get the parts with a custom order by
+
+ $qb = $repo->createQueryBuilder('part')
+ ->where('part.'.$field_name.' = :element')
+ ->setParameter('element', $element)
+ ->orderBy('NATSORT(part.name)', $nameOrderDirection);
+
+ return $qb->getQuery()->getResult();
}
protected function getPartsCountByField(object $element, string $field_name): int
diff --git a/src/Repository/AttachmentContainingDBElementRepository.php b/src/Repository/AttachmentContainingDBElementRepository.php
new file mode 100644
index 00000000..40869662
--- /dev/null
+++ b/src/Repository/AttachmentContainingDBElementRepository.php
@@ -0,0 +1,83 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Repository;
+
+use App\Doctrine\Helpers\FieldHelper;
+use App\Entity\Attachments\AttachmentContainingDBElement;
+use Doctrine\ORM\Mapping\ClassMetadata;
+
+/**
+ * @template TEntityClass of AttachmentContainingDBElement
+ * @extends NamedDBElementRepository
+ * @see \App\Tests\Repository\AttachmentContainingDBElementRepositoryTest
+ */
+class AttachmentContainingDBElementRepository extends NamedDBElementRepository
+{
+ /**
+ * @var array This array is used to cache the results of getElementsAndPreviewAttachmentByIDs function.
+ */
+ private array $elementsAndPreviewAttachmentCache = [];
+
+ /**
+ * Similar to the findByIDInMatchingOrder function, but it also hints to doctrine that the master picture attachment should be fetched eagerly.
+ * @param array $ids
+ * @return array
+ * @phpstan-return array
+ */
+ public function getElementsAndPreviewAttachmentByIDs(array $ids): array
+ {
+ //If no IDs are given, return an empty array
+ if (count($ids) === 0) {
+ return [];
+ }
+
+ //Convert the ids to a string
+ $cache_key = implode(',', $ids);
+
+ //Check if the result is already cached
+ if (isset($this->elementsAndPreviewAttachmentCache[$cache_key])) {
+ return $this->elementsAndPreviewAttachmentCache[$cache_key];
+ }
+
+ $qb = $this->createQueryBuilder('element')
+ ->select('element')
+ ->where('element.id IN (?1)')
+ //Order the results in the same order as the IDs in the input array (mysql supports this native, for SQLite we emulate it)
+ ->setParameter(1, $ids);
+
+ //Order the results in the same order as the IDs in the input array
+ FieldHelper::addOrderByFieldParam($qb, 'element.id', 1);
+
+ $q = $qb->getQuery();
+
+ $q->setFetchMode($this->getEntityName(), 'master_picture_attachment', ClassMetadata::FETCH_EAGER);
+
+ $result = $q->getResult();
+
+ //Cache the result
+ $this->elementsAndPreviewAttachmentCache[$cache_key] = $result;
+
+ return $result;
+ }
+}
\ No newline at end of file
diff --git a/src/Repository/AttachmentRepository.php b/src/Repository/AttachmentRepository.php
index 9ad9191b..4fc0abc9 100644
--- a/src/Repository/AttachmentRepository.php
+++ b/src/Repository/AttachmentRepository.php
@@ -41,9 +41,14 @@ declare(strict_types=1);
namespace App\Repository;
+use App\Entity\Attachments\Attachment;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
+/**
+ * @template TEntityClass of Attachment
+ * @extends DBElementRepository
+ */
class AttachmentRepository extends DBElementRepository
{
/**
@@ -53,15 +58,15 @@ class AttachmentRepository extends DBElementRepository
{
$qb = $this->createQueryBuilder('attachment');
$qb->select('COUNT(attachment)')
- ->where('attachment.path LIKE :like');
- $qb->setParameter('like', '\\%SECURE\\%%');
+ ->where('attachment.internal_path LIKE :like ESCAPE \'#\'');
+ $qb->setParameter('like', '#%SECURE#%%');
$query = $qb->getQuery();
return (int) $query->getSingleScalarResult();
}
/**
- * Gets the count of all external attachments (attachments only containing an URL).
+ * Gets the count of all external attachments (attachments containing only an external path).
*
* @throws NoResultException
* @throws NonUniqueResultException
@@ -70,17 +75,16 @@ class AttachmentRepository extends DBElementRepository
{
$qb = $this->createQueryBuilder('attachment');
$qb->select('COUNT(attachment)')
- ->where('attachment.path LIKE :http')
- ->orWhere('attachment.path LIKE :https');
- $qb->setParameter('http', 'http://%');
- $qb->setParameter('https', 'https://%');
+ ->where('attachment.external_path IS NOT NULL')
+ ->andWhere('attachment.internal_path IS NULL');
+
$query = $qb->getQuery();
return (int) $query->getSingleScalarResult();
}
/**
- * Gets the count of all attachments where an user uploaded an file.
+ * Gets the count of all attachments where a user uploaded a file or a file was downloaded from an external source.
*
* @throws NoResultException
* @throws NonUniqueResultException
@@ -89,12 +93,12 @@ class AttachmentRepository extends DBElementRepository
{
$qb = $this->createQueryBuilder('attachment');
$qb->select('COUNT(attachment)')
- ->where('attachment.path LIKE :base')
- ->orWhere('attachment.path LIKE :media')
- ->orWhere('attachment.path LIKE :secure');
- $qb->setParameter('secure', '\\%SECURE\\%%');
- $qb->setParameter('base', '\\%BASE\\%%');
- $qb->setParameter('media', '\\%MEDIA\\%%');
+ ->where('attachment.internal_path LIKE :base ESCAPE \'#\'')
+ ->orWhere('attachment.internal_path LIKE :media ESCAPE \'#\'')
+ ->orWhere('attachment.internal_path LIKE :secure ESCAPE \'#\'');
+ $qb->setParameter('secure', '#%SECURE#%%');
+ $qb->setParameter('base', '#%BASE#%%');
+ $qb->setParameter('media', '#%MEDIA#%%');
$query = $qb->getQuery();
return (int) $query->getSingleScalarResult();
diff --git a/src/Repository/CurrencyRepository.php b/src/Repository/CurrencyRepository.php
new file mode 100644
index 00000000..63473229
--- /dev/null
+++ b/src/Repository/CurrencyRepository.php
@@ -0,0 +1,58 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Repository;
+
+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/DBElementRepository.php b/src/Repository/DBElementRepository.php
index 0f7024b6..2437e848 100644
--- a/src/Repository/DBElementRepository.php
+++ b/src/Repository/DBElementRepository.php
@@ -41,23 +41,32 @@ declare(strict_types=1);
namespace App\Repository;
+use App\Doctrine\Helpers\FieldHelper;
use App\Entity\Base\AbstractDBElement;
use Doctrine\ORM\EntityRepository;
use ReflectionClass;
+/**
+ * @template TEntityClass of AbstractDBElement
+ * @extends EntityRepository
+ * @see \App\Tests\Repository\DBElementRepositoryTest
+ */
class DBElementRepository extends EntityRepository
{
+ private array $find_elements_by_id_cache = [];
+
/**
* Changes the ID of the given element to a new value.
* You should only use it to undelete former existing elements, everything else is most likely a bad idea!
*
* @param AbstractDBElement $element The element whose ID should be changed
- * @param int $new_id The new ID
+ * @phpstan-param TEntityClass $element
+ * @param int $new_id The new ID
*/
public function changeID(AbstractDBElement $element, int $new_id): void
{
$qb = $this->createQueryBuilder('element');
- $q = $qb->update(get_class($element), 'element')
+ $q = $qb->update($element::class, 'element')
->set('element.id', $new_id)
->where('element.id = ?1')
->setParameter(1, $element->getID())
@@ -71,8 +80,9 @@ class DBElementRepository extends EntityRepository
/**
* Find all elements that match a list of IDs.
- *
+ * They are ordered by IDs in an ascending order.
* @return AbstractDBElement[]
+ * @phpstan-return list
*/
public function getElementsFromIDArray(array $ids): array
{
@@ -80,14 +90,66 @@ class DBElementRepository extends EntityRepository
$q = $qb->select('element')
->where('element.id IN (?1)')
->setParameter(1, $ids)
+ ->orderBy('element.id', 'ASC')
->getQuery();
return $q->getResult();
}
+ /**
+ * Returns the elements with the given IDs in the same order, as they were given in the input array.
+ *
+ * @param array $ids
+ * @return array
+ */
+ public function findByIDInMatchingOrder(array $ids): array
+ {
+ //If no IDs are given, return an empty array
+ if (count($ids) === 0) {
+ return [];
+ }
+
+ $cache_key = implode(',', $ids);
+
+ //Check if the result is already cached
+ if (isset($this->find_elements_by_id_cache[$cache_key])) {
+ return $this->find_elements_by_id_cache[$cache_key];
+ }
+
+ //Otherwise do the query
+ $qb = $this->createQueryBuilder('element');
+ $qb->select('element')
+ ->where('element.id IN (?1)')
+ ->setParameter(1, $ids);
+
+ //Order the results in the same order as the IDs in the input array
+ FieldHelper::addOrderByFieldParam($qb, 'element.id', 1);
+
+ $q = $qb->getQuery();
+
+ $result = $q->getResult();
+
+ //Cache the result
+ $this->find_elements_by_id_cache[$cache_key] = $result;
+
+ return $result;
+ }
+
+ /**
+ * The elements in the result array will be sorted, so that their order of their IDs matches the order of the IDs in the input array.
+ * @param array $result_array
+ * @phpstan-param list $result_array
+ * @param int[] $ids
+ * @return void
+ */
+ protected function sortResultArrayByIDArray(array &$result_array, array $ids): void
+ {
+ usort($result_array, static fn(AbstractDBElement $a, AbstractDBElement $b) => array_search($a->getID(), $ids, true) <=> array_search($b->getID(), $ids, true));
+ }
+
protected function setField(AbstractDBElement $element, string $field, int $new_value): void
{
- $reflection = new ReflectionClass(get_class($element));
+ $reflection = new ReflectionClass($element::class);
$property = $reflection->getProperty($field);
$property->setAccessible(true);
$property->setValue($element, $new_value);
diff --git a/src/Repository/LabelProfileRepository.php b/src/Repository/LabelProfileRepository.php
index 76372f34..ad9e40f1 100644
--- a/src/Repository/LabelProfileRepository.php
+++ b/src/Repository/LabelProfileRepository.php
@@ -43,21 +43,21 @@ namespace App\Repository;
use App\Entity\LabelSystem\LabelOptions;
use App\Entity\LabelSystem\LabelProfile;
+use App\Entity\LabelSystem\LabelSupportedElement;
use App\Helpers\Trees\TreeViewNode;
-use InvalidArgumentException;
+/**
+ * @template TEntityClass of LabelProfile
+ * @extends NamedDBElementRepository
+ */
class LabelProfileRepository extends NamedDBElementRepository
{
/**
* Find the profiles that are shown in the dropdown for the given type.
* You should maybe use the cached version of this in LabelProfileDropdownHelper.
*/
- public function getDropdownProfiles(string $type): array
+ public function getDropdownProfiles(LabelSupportedElement $type): array
{
- if (!in_array($type, LabelOptions::SUPPORTED_ELEMENTS, true)) {
- throw new InvalidArgumentException('Invalid supported_element type given.');
- }
-
return $this->findBy([
'options.supported_element' => $type,
'show_in_dropdown' => true,
@@ -74,7 +74,7 @@ class LabelProfileRepository extends NamedDBElementRepository
{
$result = [];
- foreach (LabelOptions::SUPPORTED_ELEMENTS as $type) {
+ foreach (LabelSupportedElement::cases() as $type) {
$type_children = [];
$entities = $this->findForSupportedElement($type);
foreach ($entities as $entity) {
@@ -84,9 +84,9 @@ class LabelProfileRepository extends NamedDBElementRepository
$type_children[] = $node;
}
- if (!empty($type_children)) {
+ if ($type_children !== []) {
//Use default label e.g. 'part_label'. $$ marks that it will be translated in TreeViewGenerator
- $tmp = new TreeViewNode('$$'.$type.'.label', null, $type_children);
+ $tmp = new TreeViewNode('$$'.$type->value.'.label', null, $type_children);
$result[] = $tmp;
}
@@ -98,42 +98,35 @@ class LabelProfileRepository extends NamedDBElementRepository
/**
* Find all LabelProfiles that can be used with the given type.
*
- * @param string $type see LabelOptions::SUPPORTED_ELEMENTS for valid values
+ * @param LabelSupportedElement $type see LabelOptions::SUPPORTED_ELEMENTS for valid values
* @param array $order_by The way the results should be sorted. By default ordered by
*/
- public function findForSupportedElement(string $type, array $order_by = ['name' => 'ASC']): array
+ public function findForSupportedElement(LabelSupportedElement $type, array $order_by = ['name' => 'ASC']): array
{
- if (!in_array($type, LabelOptions::SUPPORTED_ELEMENTS, true)) {
- throw new InvalidArgumentException('Invalid supported_element type given.');
- }
-
return $this->findBy(['options.supported_element' => $type], $order_by);
}
/**
* Returns all LabelProfiles that can be used for parts
- * @return array
*/
public function getPartLabelProfiles(): array
{
- return $this->getDropdownProfiles('part');
+ return $this->getDropdownProfiles(LabelSupportedElement::PART);
}
/**
* Returns all LabelProfiles that can be used for part lots
- * @return array
*/
public function getPartLotsLabelProfiles(): array
{
- return $this->getDropdownProfiles('part_lot');
+ return $this->getDropdownProfiles(LabelSupportedElement::PART_LOT);
}
/**
* Returns all LabelProfiles that can be used for storelocations
- * @return array
*/
public function getStorelocationsLabelProfiles(): array
{
- return $this->getDropdownProfiles('storelocation');
+ return $this->getDropdownProfiles(LabelSupportedElement::STORELOCATION);
}
}
diff --git a/src/Repository/LogEntryRepository.php b/src/Repository/LogEntryRepository.php
index 857aac5b..6850d06b 100644
--- a/src/Repository/LogEntryRepository.php
+++ b/src/Repository/LogEntryRepository.php
@@ -28,10 +28,14 @@ use App\Entity\LogSystem\CollectionElementDeleted;
use App\Entity\LogSystem\ElementCreatedLogEntry;
use App\Entity\LogSystem\ElementDeletedLogEntry;
use App\Entity\LogSystem\ElementEditedLogEntry;
+use App\Entity\LogSystem\LogTargetType;
use App\Entity\UserSystem\User;
-use DateTime;
use RuntimeException;
+/**
+ * @template TEntityClass of AbstractLogEntry
+ * @extends DBElementRepository
+ */
class LogEntryRepository extends DBElementRepository
{
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array
@@ -41,7 +45,7 @@ class LogEntryRepository extends DBElementRepository
/** @var AbstractDBElement $element */
$element = $criteria['target'];
$criteria['target_id'] = $element->getID();
- $criteria['target_type'] = AbstractLogEntry::targetTypeClassToID(get_class($element));
+ $criteria['target_type'] = LogTargetType::fromElementClass($element);
unset($criteria['target']);
}
@@ -52,15 +56,16 @@ class LogEntryRepository extends DBElementRepository
* Find log entries associated with the given element (the history of the element).
*
* @param AbstractDBElement $element The element for which the history should be generated
- * @param string $order By default, the newest entries are shown first. Change this to ASC to show the oldest entries first.
- * @param null $limit
- * @param null $offset
+ * @param string $order By default, the newest entries are shown first. Change this to ASC to show the oldest entries first.
+ * @param int|null $limit
+ * @param int|null $offset
*
* @return AbstractLogEntry[]
*/
- public function getElementHistory(AbstractDBElement $element, string $order = 'DESC', $limit = null, $offset = null): array
+ public function getElementHistory(AbstractDBElement $element, string $order = 'DESC', ?int $limit = null, ?int $offset = null): array
{
- return $this->findBy(['element' => $element], ['timestamp' => $order], $limit, $offset);
+ //@phpstan-ignore-next-line Target is parsed dynamically in findBy
+ return $this->findBy(['target' => $element], ['timestamp' => $order], $limit, $offset);
}
/**
@@ -80,10 +85,8 @@ class LogEntryRepository extends DBElementRepository
->orderBy('log.timestamp', 'DESC')
->setMaxResults(1);
- $qb->setParameters([
- 'target_type' => AbstractLogEntry::targetTypeClassToID($class),
- 'target_id' => $id,
- ]);
+ $qb->setParameter('target_type', LogTargetType::fromElementClass($class));
+ $qb->setParameter('target_id', $id);
$query = $qb->getQuery();
@@ -100,27 +103,26 @@ class LogEntryRepository extends DBElementRepository
* Gets all log entries that are related to time travelling.
*
* @param AbstractDBElement $element The element for which the time travel data should be retrieved
- * @param DateTime $until Back to which timestamp should the data be get (including the timestamp)
+ * @param \DateTimeInterface $until Back to which timestamp should the data be got (including the timestamp)
*
* @return AbstractLogEntry[]
*/
- public function getTimetravelDataForElement(AbstractDBElement $element, DateTime $until): array
+ public function getTimetravelDataForElement(AbstractDBElement $element, \DateTimeInterface $until): array
{
$qb = $this->createQueryBuilder('log');
$qb->select('log')
- //->where('log INSTANCE OF App\Entity\LogSystem\ElementEditedLogEntry')
->where('log INSTANCE OF '.ElementEditedLogEntry::class)
->orWhere('log INSTANCE OF '.CollectionElementDeleted::class)
->andWhere('log.target_type = :target_type')
->andWhere('log.target_id = :target_id')
->andWhere('log.timestamp >= :until')
- ->orderBy('log.timestamp', 'DESC');
+ ->orderBy('log.timestamp', 'DESC')
+ ;
+
+ $qb->setParameter('target_type', LogTargetType::fromElementClass($element));
+ $qb->setParameter('target_id', $element->getID());
+ $qb->setParameter('until', $until);
- $qb->setParameters([
- 'target_type' => AbstractLogEntry::targetTypeClassToID(get_class($element)),
- 'target_id' => $element->getID(),
- 'until' => $until,
- ]);
$query = $qb->getQuery();
@@ -132,7 +134,7 @@ class LogEntryRepository extends DBElementRepository
*
* @return bool True if the element existed at the given timestamp
*/
- public function getElementExistedAtTimestamp(AbstractDBElement $element, DateTime $timestamp): bool
+ public function getElementExistedAtTimestamp(AbstractDBElement $element, \DateTimeInterface $timestamp): bool
{
$qb = $this->createQueryBuilder('log');
$qb->select('count(log)')
@@ -140,28 +142,25 @@ class LogEntryRepository extends DBElementRepository
->andWhere('log.target_type = :target_type')
->andWhere('log.target_id = :target_id')
->andWhere('log.timestamp >= :until')
- ->orderBy('log.timestamp', 'DESC');
+ ;
- $qb->setParameters([
- 'target_type' => AbstractLogEntry::targetTypeClassToID(get_class($element)),
- 'target_id' => $element->getID(),
- 'until' => $timestamp,
- ]);
+ $qb->setParameter('target_type', LogTargetType::fromElementClass($element));
+ $qb->setParameter('target_id', $element->getID());
+ $qb->setParameter('until', $timestamp);
$query = $qb->getQuery();
$count = $query->getSingleScalarResult();
- return !($count > 0);
+ return $count <= 0;
}
/**
* Gets the last log entries ordered by timestamp.
*
- * @param string $order
- * @param null $limit
- * @param null $offset
+ * @param int|null $limit
+ * @param int|null $offset
*/
- public function getLogsOrderedByTimestamp(string $order = 'DESC', $limit = null, $offset = null): array
+ public function getLogsOrderedByTimestamp(string $order = 'DESC', ?int $limit = null, ?int $offset = null): array
{
return $this->findBy([], ['timestamp' => $order], $limit, $offset);
}
@@ -205,23 +204,38 @@ class LogEntryRepository extends DBElementRepository
return $this->getLastUser($element, ElementCreatedLogEntry::class);
}
- protected function getLastUser(AbstractDBElement $element, string $class): ?User
+ /**
+ * Returns the last user that has created a log entry with the given class on the given element.
+ * @param AbstractDBElement $element
+ * @param string $log_class
+ * @return User|null
+ */
+ protected function getLastUser(AbstractDBElement $element, string $log_class): ?User
{
$qb = $this->createQueryBuilder('log');
+ /**
+ * The select and join with user here are important, to get true null user values, if the user was deleted.
+ * This happens for sqlite database, before the SET NULL constraint was added, and doctrine generates a proxy
+ * entity which fails to resolve, without this line.
+ * This was the cause of issue #414 (https://github.com/Part-DB/Part-DB-server/issues/414)
+ */
$qb->select('log')
- //->where('log INSTANCE OF App\Entity\LogSystem\ElementEditedLogEntry')
- ->where('log INSTANCE OF '.$class)
+ ->addSelect('user')
+ ->where('log INSTANCE OF '.$log_class)
+ ->leftJoin('log.user', 'user')
->andWhere('log.target_type = :target_type')
->andWhere('log.target_id = :target_id')
- ->orderBy('log.timestamp', 'DESC');
+ ->orderBy('log.timestamp', 'DESC')
+ //Use id as fallback, if timestamp is the same (higher id means newer entry)
+ ->addOrderBy('log.id', 'DESC')
+ ;
- $qb->setParameters([
- 'target_type' => AbstractLogEntry::targetTypeClassToID(get_class($element)),
- 'target_id' => $element->getID(),
- ]);
+ $qb->setParameter('target_type', LogTargetType::fromElementClass($element));
+ $qb->setParameter('target_id', $element->getID());
$query = $qb->getQuery();
$query->setMaxResults(1);
+
/** @var AbstractLogEntry[] $results */
$results = $query->execute();
if (isset($results[0])) {
diff --git a/src/Repository/NamedDBElementRepository.php b/src/Repository/NamedDBElementRepository.php
index 0985b8e1..8c78cb5e 100644
--- a/src/Repository/NamedDBElementRepository.php
+++ b/src/Repository/NamedDBElementRepository.php
@@ -26,6 +26,11 @@ use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\UserSystem\User;
use App\Helpers\Trees\TreeViewNode;
+/**
+ * @template TEntityClass of AbstractNamedDBElement
+ * @extends DBElementRepository
+ * @see \App\Tests\Repository\NamedDBElementRepositoryTest
+ */
class NamedDBElementRepository extends DBElementRepository
{
/**
@@ -38,19 +43,40 @@ class NamedDBElementRepository extends DBElementRepository
{
$result = [];
- $entities = $this->findBy([], ['name' => 'ASC']);
+ $entities = $this->getFlatList();
foreach ($entities as $entity) {
/** @var AbstractNamedDBElement $entity */
$node = new TreeViewNode($entity->getName(), null, null);
$node->setId($entity->getID());
$result[] = $node;
- if ($entity instanceof User && $entity->isDisabled()) {
- //If this is an user, then add a badge when it is disabled
- $node->setIcon('fa-fw fa-treeview fa-solid fa-user-lock text-muted');
+ if ($entity instanceof User) {
+ if ($entity->isDisabled()) {
+ //If this is a user, then add a badge when it is disabled
+ $node->setIcon('fa-fw fa-treeview fa-solid fa-user-lock text-muted');
+ }
+ if ($entity->isSamlUser()) {
+ $node->setIcon('fa-fw fa-treeview fa-solid fa-house-user text-muted');
+ }
}
+
}
return $result;
}
+
+ /**
+ * Returns a flattened list of all nodes, sorted by name in natural order.
+ * @return AbstractNamedDBElement[]
+ * @phpstan-return array
+ */
+ public function getFlatList(): array
+ {
+ $qb = $this->createQueryBuilder('e');
+ $q = $qb->select('e')
+ ->orderBy('NATSORT(e.name)', 'ASC')
+ ->getQuery();
+
+ return $q->getResult();
+ }
}
diff --git a/src/Repository/ParameterRepository.php b/src/Repository/ParameterRepository.php
index 37420306..6c6c867d 100644
--- a/src/Repository/ParameterRepository.php
+++ b/src/Repository/ParameterRepository.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Repository;
+use App\Entity\Parameters\AbstractParameter;
+
+/**
+ * @template TEntityClass of AbstractParameter
+ * @extends DBElementRepository
+ */
class ParameterRepository extends DBElementRepository
{
/**
* Find parameters using a parameter name
* @param string $name The name to search for
* @param bool $exact True, if only exact names should match. False, if the name just needs to be contained in the parameter name
- * @param int $max_results
- * @return array
+ * @phpstan-return array
*/
public function autocompleteParamName(string $name, bool $exact = false, int $max_results = 50): array
{
@@ -37,7 +44,7 @@ class ParameterRepository extends DBElementRepository
->select('parameter.name')
->addSelect('parameter.symbol')
->addSelect('parameter.unit')
- ->where('parameter.name LIKE :name');
+ ->where('ILIKE(parameter.name, :name) = TRUE');
if ($exact) {
$qb->setParameter('name', $name);
} else {
@@ -48,4 +55,4 @@ class ParameterRepository extends DBElementRepository
return $qb->getQuery()->getArrayResult();
}
-}
\ No newline at end of file
+}
diff --git a/src/Repository/PartRepository.php b/src/Repository/PartRepository.php
index 5dfb8f45..edccd74b 100644
--- a/src/Repository/PartRepository.php
+++ b/src/Repository/PartRepository.php
@@ -22,15 +22,19 @@ declare(strict_types=1);
namespace App\Repository;
+use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\QueryBuilder;
+/**
+ * @extends NamedDBElementRepository
+ */
class PartRepository extends NamedDBElementRepository
{
/**
- * Gets the summed up instock of all parts (only parts without an measurent unit).
+ * Gets the summed up instock of all parts (only parts without a measurement unit).
*
* @throws NoResultException
* @throws NonUniqueResultException
@@ -49,7 +53,7 @@ class PartRepository extends NamedDBElementRepository
}
/**
- * Gets the number of parts that has price informations.
+ * Gets the number of parts that has price information.
*
* @throws NoResultException
* @throws NonUniqueResultException
@@ -67,6 +71,9 @@ class PartRepository extends NamedDBElementRepository
return (int) ($query->getSingleScalarResult() ?? 0);
}
+ /**
+ * @return Part[]
+ */
public function autocompleteSearch(string $query, int $max_limits = 50): array
{
$qb = $this->createQueryBuilder('part');
@@ -74,16 +81,16 @@ class PartRepository extends NamedDBElementRepository
->leftJoin('part.category', 'category')
->leftJoin('part.footprint', 'footprint')
- ->where('part.name LIKE :query')
- ->orWhere('part.description LIKE :query')
- ->orWhere('category.name LIKE :query')
- ->orWhere('footprint.name LIKE :query')
+ ->where('ILIKE(part.name, :query) = TRUE')
+ ->orWhere('ILIKE(part.description, :query) = TRUE')
+ ->orWhere('ILIKE(category.name, :query) = TRUE')
+ ->orWhere('ILIKE(footprint.name, :query) = TRUE')
;
$qb->setParameter('query', '%'.$query.'%');
$qb->setMaxResults($max_limits);
- $qb->orderBy('part.name', 'ASC');
+ $qb->orderBy('NATSORT(part.name)', 'ASC');
return $qb->getQuery()->getResult();
}
diff --git a/src/Repository/Parts/CategoryRepository.php b/src/Repository/Parts/CategoryRepository.php
index c472d8d6..9bbb1e37 100644
--- a/src/Repository/Parts/CategoryRepository.php
+++ b/src/Repository/Parts/CategoryRepository.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Repository\Parts;
use App\Entity\Parts\Category;
@@ -26,13 +28,13 @@ use InvalidArgumentException;
class CategoryRepository extends AbstractPartsContainingRepository
{
- public function getParts(object $element, array $order_by = ['name' => 'ASC']): array
+ public function getParts(object $element, string $nameOrderDirection = "ASC"): array
{
if (!$element instanceof Category) {
throw new InvalidArgumentException('$element must be an Category!');
}
- return $this->getPartsByField($element, $order_by, 'category');
+ return $this->getPartsByField($element, $nameOrderDirection, 'category');
}
public function getPartsCount(object $element): int
diff --git a/src/Repository/Parts/DeviceRepository.php b/src/Repository/Parts/DeviceRepository.php
index dc5d5acc..442c91e5 100644
--- a/src/Repository/Parts/DeviceRepository.php
+++ b/src/Repository/Parts/DeviceRepository.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Repository\Parts;
-use App\Entity\Base\AbstractPartsContainingDBElement;
use App\Entity\ProjectSystem\Project;
-use App\Entity\Parts\Category;
-use App\Entity\Parts\Part;
-use App\Repository\AbstractPartsContainingRepository;
use App\Repository\StructuralDBElementRepository;
use InvalidArgumentException;
@@ -53,4 +51,4 @@ class DeviceRepository extends StructuralDBElementRepository
//Prevent user from deleting devices, to not accidentally remove filled devices from old versions
return 1;
}
-}
\ No newline at end of file
+}
diff --git a/src/Repository/Parts/FootprintRepository.php b/src/Repository/Parts/FootprintRepository.php
index 72c25003..8934831a 100644
--- a/src/Repository/Parts/FootprintRepository.php
+++ b/src/Repository/Parts/FootprintRepository.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Repository\Parts;
use App\Entity\Parts\Footprint;
@@ -26,13 +28,13 @@ use InvalidArgumentException;
class FootprintRepository extends AbstractPartsContainingRepository
{
- public function getParts(object $element, array $order_by = ['name' => 'ASC']): array
+ public function getParts(object $element, string $nameOrderDirection = "ASC"): array
{
if (!$element instanceof Footprint) {
throw new InvalidArgumentException('$element must be an Footprint!');
}
- return $this->getPartsByField($element, $order_by, 'footprint');
+ return $this->getPartsByField($element, $nameOrderDirection, 'footprint');
}
public function getPartsCount(object $element): int
diff --git a/src/Repository/Parts/ManufacturerRepository.php b/src/Repository/Parts/ManufacturerRepository.php
index aa4d8fec..1a838710 100644
--- a/src/Repository/Parts/ManufacturerRepository.php
+++ b/src/Repository/Parts/ManufacturerRepository.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Repository\Parts;
use App\Entity\Parts\Manufacturer;
@@ -26,13 +28,13 @@ use InvalidArgumentException;
class ManufacturerRepository extends AbstractPartsContainingRepository
{
- public function getParts(object $element, array $order_by = ['name' => 'ASC']): array
+ public function getParts(object $element, string $nameOrderDirection = "ASC"): array
{
if (!$element instanceof Manufacturer) {
throw new InvalidArgumentException('$element must be an Manufacturer!');
}
- return $this->getPartsByField($element, $order_by, 'manufacturer');
+ return $this->getPartsByField($element, $nameOrderDirection, 'manufacturer');
}
public function getPartsCount(object $element): int
diff --git a/src/Repository/Parts/MeasurementUnitRepository.php b/src/Repository/Parts/MeasurementUnitRepository.php
index 80d32743..c581f751 100644
--- a/src/Repository/Parts/MeasurementUnitRepository.php
+++ b/src/Repository/Parts/MeasurementUnitRepository.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Repository\Parts;
use App\Entity\Parts\MeasurementUnit;
@@ -26,13 +28,13 @@ use InvalidArgumentException;
class MeasurementUnitRepository extends AbstractPartsContainingRepository
{
- public function getParts(object $element, array $order_by = ['name' => 'ASC']): array
+ public function getParts(object $element, string $nameOrderDirection = "ASC"): array
{
if (!$element instanceof MeasurementUnit) {
throw new InvalidArgumentException('$element must be an MeasurementUnit!');
}
- return $this->getPartsByField($element, $order_by, 'partUnit');
+ return $this->getPartsByField($element, $nameOrderDirection, 'partUnit');
}
public function getPartsCount(object $element): int
diff --git a/src/Repository/Parts/StorelocationRepository.php b/src/Repository/Parts/StorelocationRepository.php
index c0c432be..82317868 100644
--- a/src/Repository/Parts/StorelocationRepository.php
+++ b/src/Repository/Parts/StorelocationRepository.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Repository\Parts;
use App\Entity\Parts\Part;
-use App\Entity\Parts\Storelocation;
+use App\Entity\Parts\StorageLocation;
use App\Repository\AbstractPartsContainingRepository;
use Doctrine\ORM\QueryBuilder;
use InvalidArgumentException;
class StorelocationRepository extends AbstractPartsContainingRepository
{
- /**
- * @param object $element
- * @param array $order_by
- * @return array
- */
- public function getParts(object $element, array $order_by = ['name' => 'ASC']): array
+ public function getParts(object $element, string $nameOrderDirection = "ASC"): array
{
- if (!$element instanceof Storelocation) {
+ if (!$element instanceof StorageLocation) {
throw new InvalidArgumentException('$element must be an Storelocation!');
}
@@ -45,18 +42,16 @@ class StorelocationRepository extends AbstractPartsContainingRepository
->from(Part::class, 'part')
->leftJoin('part.partLots', 'lots')
->where('lots.storage_location = ?1')
- ->setParameter(1, $element);
-
- foreach ($order_by as $field => $order) {
- $qb->addOrderBy('part.'.$field, $order);
- }
+ ->setParameter(1, $element)
+ ->orderBy('NATSORT(part.name)', $nameOrderDirection)
+ ;
return $qb->getQuery()->getResult();
}
public function getPartsCount(object $element): int
{
- if (!$element instanceof Storelocation) {
+ if (!$element instanceof StorageLocation) {
throw new InvalidArgumentException('$element must be an Storelocation!');
}
diff --git a/src/Repository/Parts/SupplierRepository.php b/src/Repository/Parts/SupplierRepository.php
index 0a2e2c8f..393ae593 100644
--- a/src/Repository/Parts/SupplierRepository.php
+++ b/src/Repository/Parts/SupplierRepository.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Repository\Parts;
use App\Entity\Parts\Part;
@@ -28,7 +30,7 @@ use InvalidArgumentException;
class SupplierRepository extends AbstractPartsContainingRepository
{
- public function getParts(object $element, array $order_by = ['name' => 'ASC']): array
+ public function getParts(object $element, string $nameOrderDirection = "ASC"): array
{
if (!$element instanceof Supplier) {
throw new InvalidArgumentException('$element must be an Supplier!');
@@ -40,11 +42,9 @@ class SupplierRepository extends AbstractPartsContainingRepository
->from(Part::class, 'part')
->leftJoin('part.orderdetails', 'orderdetail')
->where('orderdetail.supplier = ?1')
- ->setParameter(1, $element);
-
- foreach ($order_by as $field => $order) {
- $qb->addOrderBy('part.'.$field, $order);
- }
+ ->setParameter(1, $element)
+ ->orderBy('NATSORT(part.name)', $nameOrderDirection)
+ ;
return $qb->getQuery()->getResult();
}
diff --git a/src/Repository/StructuralDBElementRepository.php b/src/Repository/StructuralDBElementRepository.php
index e23eda8f..781c7622 100644
--- a/src/Repository/StructuralDBElementRepository.php
+++ b/src/Repository/StructuralDBElementRepository.php
@@ -27,8 +27,41 @@ use App\Helpers\Trees\StructuralDBElementIterator;
use App\Helpers\Trees\TreeViewNode;
use RecursiveIteratorIterator;
-class StructuralDBElementRepository extends NamedDBElementRepository
+/**
+ * @see \App\Tests\Repository\StructuralDBElementRepositoryTest
+ * @template TEntityClass of AbstractStructuralDBElement
+ * @extends AttachmentContainingDBElementRepository
+ */
+class StructuralDBElementRepository extends AttachmentContainingDBElementRepository
{
+ /**
+ * @var array An array containing all new entities created by getNewEntityByPath.
+ * This is used to prevent creating multiple entities for the same path.
+ */
+ private array $new_entity_cache = [];
+
+ /**
+ * Finds all nodes for the given parent node, ordered by name in a natural sort way
+ * @param AbstractStructuralDBElement|null $parent
+ * @param string $nameOrdering The ordering of the names. Either ASC or DESC
+ * @return array
+ */
+ public function findNodesForParent(?AbstractStructuralDBElement $parent, string $nameOrdering = "ASC"): array
+ {
+ $qb = $this->createQueryBuilder('e');
+ $qb->select('e')
+ ->orderBy('NATSORT(e.name)', $nameOrdering);
+
+ if ($parent !== null) {
+ $qb->where('e.parent = :parent')
+ ->setParameter('parent', $parent);
+ } else {
+ $qb->where('e.parent IS NULL');
+ }
+ //@phpstan-ignore-next-line [parent is only defined by the sub classes]
+ return $qb->getQuery()->getResult();
+ }
+
/**
* Finds all nodes without a parent node. They are our root nodes.
*
@@ -36,14 +69,15 @@ class StructuralDBElementRepository extends NamedDBElementRepository
*/
public function findRootNodes(): array
{
- return $this->findBy(['parent' => null], ['name' => 'ASC']);
+ return $this->findNodesForParent(null);
}
/**
* Gets a tree of TreeViewNode elements. The root elements has $parent as parent.
* The treeview is generic, that means the href are null and ID values are set.
*
- * @param AbstractStructuralDBElement|null $parent the parent the root elements should have
+ * @param AbstractStructuralDBElement|null $parent the parent the root elements should have
+ * @phpstan-param TEntityClass|null $parent
*
* @return TreeViewNode[]
*/
@@ -51,7 +85,7 @@ class StructuralDBElementRepository extends NamedDBElementRepository
{
$result = [];
- $entities = $this->findBy(['parent' => $parent], ['name' => 'ASC']);
+ $entities = $this->findNodesForParent($parent);
foreach ($entities as $entity) {
/** @var AbstractStructuralDBElement $entity */
//Make a recursive call to find all children nodes
@@ -69,20 +103,21 @@ class StructuralDBElementRepository extends NamedDBElementRepository
* Gets a flattened hierarchical tree. Useful for generating option lists.
*
* @param AbstractStructuralDBElement|null $parent This entity will be used as root element. Set to null, to use global root
- *
+ * @phpstan-param TEntityClass|null $parent
* @return AbstractStructuralDBElement[] a flattened list containing the tree elements
+ * @phpstan-return array
*/
- public function toNodesList(?AbstractStructuralDBElement $parent = null): array
+ public function getFlatList(?AbstractStructuralDBElement $parent = null): array
{
$result = [];
- $entities = $this->findBy(['parent' => $parent], ['name' => 'ASC']);
+ $entities = $this->findNodesForParent($parent);
$elementIterator = new StructuralDBElementIterator($entities);
$recursiveIterator = new RecursiveIteratorIterator($elementIterator, RecursiveIteratorIterator::SELF_FIRST);
//$result = iterator_to_array($recursiveIterator);
- //We can not use iterator_to_array here or we get only the parent elements
+ //We can not use iterator_to_array here, or we get only the parent elements
foreach ($recursiveIterator as $item) {
$result[] = $item;
}
@@ -91,12 +126,11 @@ class StructuralDBElementRepository extends NamedDBElementRepository
}
/**
- * Creates a structure of AbsstractStructuralDBElements from a path separated by $separator, which splits the various levels.
+ * Creates a structure of AbstractStructuralDBElements from a path separated by $separator, which splits the various levels.
* This function will try to use existing elements, if they are already in the database. If not, they will be created.
* An array of the created elements will be returned, with the last element being the deepest element.
- * @param string $path
- * @param string $separator
* @return AbstractStructuralDBElement[]
+ * @phpstan-return array
*/
public function getNewEntityFromPath(string $path, string $separator = '->'): array
{
@@ -108,14 +142,63 @@ class StructuralDBElementRepository extends NamedDBElementRepository
continue;
}
+ //Use the cache to prevent creating multiple entities for the same path
+ $entity = $this->getNewEntityFromCache($name, $parent);
+
+ //See if we already have an element with this name and parent in the database
+ if (!$entity instanceof AbstractStructuralDBElement) {
+ $entity = $this->findOneBy(['name' => $name, 'parent' => $parent]);
+ }
+ if (null === $entity) {
+ $class = $this->getClassName();
+ /** @var TEntityClass $entity */
+ $entity = new $class;
+ $entity->setName($name);
+ $entity->setParent($parent);
+
+ $this->setNewEntityToCache($entity);
+ }
+
+ $result[] = $entity;
+ $parent = $entity;
+ }
+
+ return $result;
+ }
+
+ private function getNewEntityFromCache(string $name, ?AbstractStructuralDBElement $parent): ?AbstractStructuralDBElement
+ {
+ $key = $parent instanceof AbstractStructuralDBElement ? $parent->getFullPath('%->%').'%->%'.$name : $name;
+ return $this->new_entity_cache[$key] ?? null;
+ }
+
+ private function setNewEntityToCache(AbstractStructuralDBElement $entity): void
+ {
+ $key = $entity->getFullPath('%->%');
+ $this->new_entity_cache[$key] = $entity;
+ }
+
+ /**
+ * Returns an element of AbstractStructuralDBElements queried from a path separated by $separator, which splits the various levels.
+ * An array of the created elements will be returned, with the last element being the deepest element.
+ * If no element was found, an empty array will be returned.
+ * @return AbstractStructuralDBElement[]
+ * @phpstan-return array
+ */
+ public function getEntityByPath(string $path, string $separator = '->'): array
+ {
+ $parent = null;
+ $result = [];
+ foreach (explode($separator, $path) as $name) {
+ $name = trim($name);
+ if ('' === $name) {
+ continue;
+ }
+
//See if we already have an element with this name and parent
$entity = $this->findOneBy(['name' => $name, 'parent' => $parent]);
if (null === $entity) {
- $class = $this->getClassName();
- /** @var AbstractStructuralDBElement $entity */
- $entity = new $class;
- $entity->setName($name);
- $entity->setParent($parent);
+ return [];
}
$result[] = $entity;
@@ -124,4 +207,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 !== null) {
+ return $entity;
+ }
+
+ $class = $this->getClassName();
+ /** @var TEntityClass $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/Repository/UserRepository.php b/src/Repository/UserRepository.php
index 2d4fea12..bbaa2b39 100644
--- a/src/Repository/UserRepository.php
+++ b/src/Repository/UserRepository.php
@@ -23,7 +23,10 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\UserSystem\User;
+use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\NonUniqueResultException;
+use Doctrine\ORM\Query\Parameter;
+use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
@@ -32,18 +35,21 @@ use Symfony\Component\Security\Core\User\UserInterface;
* @method User|null findOneBy(array $criteria, array $orderBy = null)
* @method User[] findAll()
* @method User[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ * @extends NamedDBElementRepository
+ * @see \App\Tests\Repository\UserRepositoryTest
*/
final class UserRepository extends NamedDBElementRepository implements PasswordUpgraderInterface
{
- protected $anonymous_user;
+ protected ?User $anonymous_user = null;
/**
* Returns the anonymous user.
* The result is cached, so the database is only called once, after the anonymous user was found.
+ * @return User|null The user if it is existing, null if no one matched the criteria
*/
public function getAnonymousUser(): ?User
{
- if (null === $this->anonymous_user) {
+ if (!$this->anonymous_user instanceof User) {
$this->anonymous_user = $this->findOneBy([
'id' => User::ID_ANONYMOUS,
]);
@@ -52,6 +58,30 @@ final class UserRepository extends NamedDBElementRepository implements PasswordU
return $this->anonymous_user;
}
+ /**
+ * Find a user by its username.
+ * @param string $username
+ * @return User|null
+ */
+ public function findByUsername(string $username): ?User
+ {
+ if ($username === '') {
+ return null;
+ }
+
+ $qb = $this->createQueryBuilder('u');
+ $qb->select('u')
+ ->where('u.name = (:name)');
+
+ $qb->setParameter('name', $username);
+
+ try {
+ return $qb->getQuery()->getOneOrNullResult();
+ } catch (NonUniqueResultException) {
+ return null;
+ }
+ }
+
/**
* Find a user by its name or its email. Useful for login or password reset purposes.
*
@@ -61,7 +91,7 @@ final class UserRepository extends NamedDBElementRepository implements PasswordU
*/
public function findByEmailOrName(string $name_or_password): ?User
{
- if (empty($name_or_password)) {
+ if ($name_or_password === '') {
return null;
}
@@ -70,23 +100,43 @@ final class UserRepository extends NamedDBElementRepository implements PasswordU
->where('u.name = (:name)')
->orWhere('u.email = (:email)');
- $qb->setParameters([
- 'email' => $name_or_password,
- 'name' => $name_or_password,
- ]);
+ $qb->setParameter('email', $name_or_password);
+ $qb->setParameter('name', $name_or_password);
try {
return $qb->getQuery()->getOneOrNullResult();
- } catch (NonUniqueResultException $nonUniqueResultException) {
+ } catch (NonUniqueResultException) {
return null;
}
}
- public function upgradePassword(UserInterface $user, string $newHashedPassword): void
+ public function upgradePassword(UserInterface|PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if ($user instanceof User) {
$user->setPassword($newHashedPassword);
$this->getEntityManager()->flush();
}
}
+
+ /**
+ * Returns the list of all local users (not SAML users).
+ * @return User[]
+ */
+ public function onlyLocalUsers(): array
+ {
+ return $this->findBy([
+ 'saml_user' => false,
+ ]);
+ }
+
+ /**
+ * Returns the list of all SAML users.
+ * @return User[]
+ */
+ public function onlySAMLUsers(): array
+ {
+ return $this->findBy([
+ 'saml_user' => true,
+ ]);
+ }
}
diff --git a/assets/controllers/pages/u2f_register_controller.js b/src/Repository/UserSystem/ApiTokenRepository.php
similarity index 74%
rename from assets/controllers/pages/u2f_register_controller.js
rename to src/Repository/UserSystem/ApiTokenRepository.php
index ffa4c3f2..609014f3 100644
--- a/assets/controllers/pages/u2f_register_controller.js
+++ b/src/Repository/UserSystem/ApiTokenRepository.php
@@ -1,7 +1,8 @@
+.
*/
-import {Controller} from "@hotwired/stimulus";
+declare(strict_types=1);
-export default class extends Controller
+
+namespace App\Repository\UserSystem;
+
+use Doctrine\ORM\EntityRepository;
+
+class ApiTokenRepository extends EntityRepository
{
- connect() {
- this.element.onclick = function() {
- window.u2fauth.register();
- }
- }
-}
+}
\ No newline at end of file
diff --git a/src/Security/ApiTokenAuthenticatedToken.php b/src/Security/ApiTokenAuthenticatedToken.php
new file mode 100644
index 00000000..2f186e63
--- /dev/null
+++ b/src/Security/ApiTokenAuthenticatedToken.php
@@ -0,0 +1,52 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Security;
+
+use App\Entity\UserSystem\ApiToken;
+use Symfony\Component\Security\Core\User\UserInterface;
+use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken;
+
+class ApiTokenAuthenticatedToken extends PostAuthenticationToken
+{
+ public function __construct(UserInterface $user, string $firewallName, array $roles, private readonly ApiToken $apiToken)
+ {
+ //Add roles for the API
+ $roles[] = 'ROLE_API_AUTHENTICATED';
+
+ //Add roles based on the token level
+ $roles = array_merge($roles, $apiToken->getLevel()->getAdditionalRoles());
+
+
+ parent::__construct($user, $firewallName, array_unique($roles));
+ }
+
+ /**
+ * Returns the API token that was used to authenticate the user.
+ * @return ApiToken
+ */
+ public function getApiToken(): ApiToken
+ {
+ return $this->apiToken;
+ }
+}
\ No newline at end of file
diff --git a/src/Security/ApiTokenAuthenticator.php b/src/Security/ApiTokenAuthenticator.php
new file mode 100644
index 00000000..a52b1f7c
--- /dev/null
+++ b/src/Security/ApiTokenAuthenticator.php
@@ -0,0 +1,156 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Security;
+
+use App\Entity\UserSystem\ApiToken;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Exception\AuthenticationException;
+use Symfony\Component\Security\Core\Exception\BadCredentialsException;
+use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
+use Symfony\Component\Security\Http\AccessToken\AccessTokenExtractorInterface;
+use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
+use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
+use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
+use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+/**
+ * Authenticator similar to the builtin AccessTokenAuthenticator, but we return a Token here which contains information
+ * about the used token.
+ */
+class ApiTokenAuthenticator implements AuthenticatorInterface
+{
+ public function __construct(
+ #[Autowire(service: 'security.access_token_extractor.main')]
+ private readonly AccessTokenExtractorInterface $accessTokenExtractor,
+ private readonly TranslatorInterface $translator,
+ private readonly EntityManagerInterface $entityManager,
+ private readonly string $realm = 'api',
+ ) {
+ }
+
+ /**
+ * Gets the ApiToken belonging to the given accessToken string.
+ * If the token is invalid or expired, an exception is thrown and authentication fails.
+ * @param string $accessToken
+ * @return ApiToken
+ */
+ private function getTokenFromString(#[\SensitiveParameter] string $accessToken): ApiToken
+ {
+ $repo = $this->entityManager->getRepository(ApiToken::class);
+ $token = $repo->findOneBy(['token' => $accessToken]);
+
+ if (!$token instanceof ApiToken) {
+ throw new BadCredentialsException();
+ }
+
+ if (!$token->isValid()) {
+ throw new CustomUserMessageAuthenticationException('Token expired');
+ }
+
+ $old_time = $token->getLastTimeUsed();
+ //Set the last used date of the token
+ $token->setLastTimeUsed(new \DateTimeImmutable());
+ //Only flush the token if the last used date change is more than 10 minutes
+ //For performance reasons we don't want to flush the token every time it is used, but only if it is used more than 10 minutes after the last time it was used
+ //If a flush is later in the code we don't want to flush the token again
+ if ($old_time === null || $old_time->diff($token->getLastTimeUsed())->i > 10) {
+ $this->entityManager->flush();
+ }
+
+ return $token;
+ }
+
+ public function supports(Request $request): ?bool
+ {
+ return null === $this->accessTokenExtractor->extractAccessToken($request) ? false : null;
+ }
+
+ public function authenticate(Request $request): Passport
+ {
+ $accessToken = $this->accessTokenExtractor->extractAccessToken($request);
+ if (!$accessToken) {
+ throw new BadCredentialsException('Invalid credentials.');
+ }
+
+ $apiToken = $this->getTokenFromString($accessToken);
+ $userBadge = new UserBadge($apiToken->getUser()?->getUserIdentifier() ?? throw new BadCredentialsException('Invalid credentials.'));
+ $apiBadge = new ApiTokenBadge($apiToken);
+
+ return new SelfValidatingPassport($userBadge, [$apiBadge]);
+ }
+
+ public function createToken(Passport $passport, string $firewallName): TokenInterface
+ {
+ return new ApiTokenAuthenticatedToken(
+ $passport->getUser(),
+ $firewallName,
+ $passport->getUser()->getRoles(),
+ $passport->getBadge(ApiTokenBadge::class)?->getApiToken() ?? throw new \LogicException('Passport does not contain an API token.')
+ );
+ }
+
+
+ public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
+ {
+ $errorMessage = $this->translator->trans($exception->getMessageKey(), $exception->getMessageData(),
+ 'security');
+
+ return new Response(
+ null,
+ Response::HTTP_UNAUTHORIZED,
+ ['WWW-Authenticate' => $this->getAuthenticateHeader($errorMessage)]
+ );
+ }
+
+ /**
+ * @see https://datatracker.ietf.org/doc/html/rfc6750#section-3
+ */
+ private function getAuthenticateHeader(?string $errorDescription = null): string
+ {
+ $data = [
+ 'realm' => $this->realm,
+ 'error' => 'invalid_token',
+ 'error_description' => $errorDescription,
+ ];
+ $values = [];
+ foreach ($data as $k => $v) {
+ if (null === $v || '' === $v) {
+ continue;
+ }
+ $values[] = sprintf('%s="%s"', $k, $v);
+ }
+
+ return sprintf('Bearer %s', implode(',', $values));
+ }
+
+ public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
+ {
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/src/Security/ApiTokenBadge.php b/src/Security/ApiTokenBadge.php
new file mode 100644
index 00000000..d2429a06
--- /dev/null
+++ b/src/Security/ApiTokenBadge.php
@@ -0,0 +1,51 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Security;
+
+use App\Entity\UserSystem\ApiToken;
+use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
+
+class ApiTokenBadge implements BadgeInterface
+{
+
+ /**
+ * @param ApiToken $apiToken
+ */
+ public function __construct(private readonly ApiToken $apiToken)
+ {
+ }
+
+ /**
+ * @return ApiToken The token that was used to authenticate the user
+ */
+ public function getApiToken(): ApiToken
+ {
+ return $this->apiToken;
+ }
+
+ public function isResolved(): bool
+ {
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/src/Security/AuthenticationEntryPoint.php b/src/Security/AuthenticationEntryPoint.php
new file mode 100644
index 00000000..41f624b2
--- /dev/null
+++ b/src/Security/AuthenticationEntryPoint.php
@@ -0,0 +1,89 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Security;
+
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpFoundation\Session\Session;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Component\Security\Core\Exception\AuthenticationException;
+use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
+
+use function Symfony\Component\Translation\t;
+
+/**
+ * This class decides, what to do, when a user tries to access a page, that requires authentication and he is not
+ * authenticated / logged in yet.
+ * For browser requests, the user is redirected to the login page, for API requests, a 401 response with a JSON encoded
+ * message is returned.
+ */
+class AuthenticationEntryPoint implements AuthenticationEntryPointInterface
+{
+ public function __construct(
+ private readonly UrlGeneratorInterface $urlGenerator,
+ ) {
+ }
+
+ public function start(Request $request, ?AuthenticationException $authException = null): Response
+ {
+ //Check if the request is an API request
+ if ($this->isJSONRequest($request)) {
+ //If it is, we return a 401 response with a JSON body
+ return new JsonResponse([
+ 'title' => 'Unauthorized',
+ 'detail' => 'Authentication is required. Please pass a valid API token in the Authorization header.',
+ ], Response::HTTP_UNAUTHORIZED);
+ }
+
+ //Otherwise we redirect to the login page
+
+ //Add a nice flash message to make it clear what happened
+ if ($request->getSession() instanceof Session) {
+ $request->getSession()->getFlashBag()->add('error', t('login.flash.access_denied_please_login'));
+ }
+
+ return new RedirectResponse($this->urlGenerator->generate('login'));
+ }
+
+ private function isJSONRequest(Request $request): bool
+ {
+ //If either the content type or accept header is a json type, we assume it is an API request
+ $contentType = $request->headers->get('Content-Type');
+ $accept = $request->headers->get('Accept');
+
+ $tmp = false;
+
+ if ($contentType !== null) {
+ $tmp = str_contains($contentType, 'json');
+ }
+
+ if ($accept !== null) {
+ $tmp = $tmp || str_contains($accept, 'json');
+ }
+
+ return $tmp;
+ }
+}
\ No newline at end of file
diff --git a/src/Security/EnsureSAMLUserForSAMLLoginChecker.php b/src/Security/EnsureSAMLUserForSAMLLoginChecker.php
new file mode 100644
index 00000000..0ebf893c
--- /dev/null
+++ b/src/Security/EnsureSAMLUserForSAMLLoginChecker.php
@@ -0,0 +1,71 @@
+.
+ */
+namespace App\Security;
+
+use App\Entity\UserSystem\User;
+use Nbgrp\OneloginSamlBundle\Security\Http\Authenticator\Token\SamlToken;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
+use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent;
+use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+/**
+ * @see \App\Tests\Security\EnsureSAMLUserForSAMLLoginCheckerTest
+ */
+class EnsureSAMLUserForSAMLLoginChecker implements EventSubscriberInterface
+{
+ public function __construct(private readonly TranslatorInterface $translator)
+ {
+ }
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ AuthenticationSuccessEvent::class => 'onAuthenticationSuccess',
+ ];
+ }
+
+ public function onAuthenticationSuccess(AuthenticationSuccessEvent $event): void
+ {
+ $token = $event->getAuthenticationToken();
+ $user = $token->getUser();
+
+ //Do not check for anonymous users
+ if (!$user instanceof User) {
+ return;
+ }
+
+ //Do not allow SAML users to login as local user
+ if ($token instanceof SamlToken && !$user->isSamlUser()) {
+ throw new CustomUserMessageAccountStatusException($this->translator->trans('saml.error.cannot_login_local_user_per_saml',
+ [], 'security'));
+ }
+
+ //Do not allow local users to login as SAML user via local username and password
+ if ($token instanceof UsernamePasswordToken && $user->isSamlUser()) {
+ //Ensure that you can not login locally with a SAML user (even though this should not happen, as the password is not set)
+ throw new CustomUserMessageAccountStatusException($this->translator->trans('saml.error.cannot_login_saml_user_locally', [], 'security'));
+ }
+ }
+}
diff --git a/src/Security/SamlUserFactory.php b/src/Security/SamlUserFactory.php
new file mode 100644
index 00000000..312be859
--- /dev/null
+++ b/src/Security/SamlUserFactory.php
@@ -0,0 +1,153 @@
+.
+ */
+namespace App\Security;
+
+use App\Entity\UserSystem\Group;
+use App\Entity\UserSystem\User;
+use Doctrine\ORM\EntityManagerInterface;
+use Nbgrp\OneloginSamlBundle\Security\Http\Authenticator\Token\SamlToken;
+use Nbgrp\OneloginSamlBundle\Security\User\SamlUserFactoryInterface;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent;
+use Symfony\Component\Security\Core\User\UserInterface;
+
+/**
+ * @see \App\Tests\Security\SamlUserFactoryTest
+ */
+class SamlUserFactory implements SamlUserFactoryInterface, EventSubscriberInterface
+{
+ private readonly array $saml_role_mapping;
+
+ public function __construct(private readonly EntityManagerInterface $em, ?array $saml_role_mapping, private readonly bool $update_group_on_login)
+ {
+ $this->saml_role_mapping = $saml_role_mapping ?: [];
+ }
+
+ final public const SAML_PASSWORD_PLACEHOLDER = '!!SAML!!';
+
+ public function createUser($username, array $attributes = []): UserInterface
+ {
+ $user = new User();
+ $user->setName($username);
+ $user->setNeedPwChange(false);
+ $user->setPassword(self::SAML_PASSWORD_PLACEHOLDER);
+ //This is a SAML user now!
+ $user->setSamlUser(true);
+
+ //Update basic user information
+ $user->setSamlAttributes($attributes);
+
+ //Check if we can find a group for this user based on the SAML attributes
+ $group = $this->mapSAMLAttributesToLocalGroup($attributes);
+ $user->setGroup($group);
+
+ return $user;
+ }
+
+ /**
+ * This method is called after a successful authentication. It is used to update the group of the user,
+ * based on the new SAML attributes.
+ */
+ public function onAuthenticationSuccess(AuthenticationSuccessEvent $event): void
+ {
+ if (! $this->update_group_on_login) {
+ return;
+ }
+
+ $token = $event->getAuthenticationToken();
+ $user = $token->getUser();
+ //Only update the group if the user is a SAML user
+ if (! $token instanceof SamlToken || ! $user instanceof User) {
+ return;
+ }
+
+ //Check if we can find a group for this user based on the SAML attributes
+ $group = $this->mapSAMLAttributesToLocalGroup($token->getAttributes());
+ //If needed update the group of the user and save it to DB
+ if ($group !== $user->getGroup()) {
+ $user->setGroup($group);
+ $this->em->flush();
+ }
+ }
+
+ /**
+ * Maps the given SAML attributes to a local group.
+ * @param array $attributes The SAML attributes
+ */
+ public function mapSAMLAttributesToLocalGroup(array $attributes): ?Group
+ {
+ //Extract the roles from the SAML attributes
+ $roles = $attributes['group'] ?? [];
+ $group_id = $this->mapSAMLRolesToLocalGroupID($roles);
+
+ //Check if we can find a group with the given ID
+ if ($group_id !== null) {
+ $group = $this->em->find(Group::class, $group_id);
+ if ($group instanceof Group) {
+ return $group;
+ }
+ }
+
+ //If no group was found, return null
+ return null;
+ }
+
+ /**
+ * Maps a list of SAML roles to a local group ID.
+ * The first available mapping will be used (so the order of the $map is important, first match wins).
+ * @param array $roles The list of SAML roles
+ * @param array|null $map The mapping from SAML roles. If null, the global mapping will be used.
+ * @return int|null The ID of the local group or null if no mapping was found.
+ */
+ public function mapSAMLRolesToLocalGroupID(array $roles, ?array $map = null): ?int
+ {
+ $map ??= $this->saml_role_mapping;
+
+ //Iterate over the mapping (from first to last) and check if we have a match
+ foreach ($map as $saml_role => $group_id) {
+ //Skip wildcard
+ if ($saml_role === '*') {
+ continue;
+ }
+ if (in_array($saml_role, $roles, true)) {
+ return (int) $group_id;
+ }
+ }
+
+
+ //If no applicable mapping was found, check if we have a default mapping
+ if (array_key_exists('*', $map)) {
+ return (int) $map['*'];
+ }
+
+ //If no mapping was found, return null
+ return null;
+ }
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ AuthenticationSuccessEvent::class => 'onAuthenticationSuccess',
+ ];
+ }
+}
diff --git a/src/Security/TwoFactor/WebauthnKeyLastUseTwoFactorProvider.php b/src/Security/TwoFactor/WebauthnKeyLastUseTwoFactorProvider.php
new file mode 100644
index 00000000..9bfa691d
--- /dev/null
+++ b/src/Security/TwoFactor/WebauthnKeyLastUseTwoFactorProvider.php
@@ -0,0 +1,106 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Security\TwoFactor;
+
+use App\Entity\UserSystem\WebauthnKey;
+use Doctrine\ORM\EntityManagerInterface;
+use Jbtronics\TFAWebauthn\Services\UserPublicKeyCredentialSourceRepository;
+use Jbtronics\TFAWebauthn\Services\WebauthnProvider;
+use Scheb\TwoFactorBundle\Security\TwoFactor\AuthenticationContextInterface;
+use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\TwoFactorFormRendererInterface;
+use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\TwoFactorProviderInterface;
+use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
+use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
+
+/**
+ * This class decorates the Webauthn TwoFactorProvider and adds additional logic which allows us to set a last used date
+ * on the used webauthn key, which can be viewed in the user settings.
+ */
+#[AsDecorator('jbtronics_webauthn_tfa.two_factor_provider')]
+class WebauthnKeyLastUseTwoFactorProvider implements TwoFactorProviderInterface
+{
+
+ public function __construct(
+ #[AutowireDecorated]
+ private readonly TwoFactorProviderInterface $decorated,
+ private readonly EntityManagerInterface $entityManager,
+ #[Autowire(service: 'jbtronics_webauthn_tfa.user_public_key_source_repo')]
+ private readonly UserPublicKeyCredentialSourceRepository $publicKeyCredentialSourceRepository,
+ #[Autowire(service: 'jbtronics_webauthn_tfa.webauthn_provider')]
+ private readonly WebauthnProvider $webauthnProvider,
+ )
+ {
+ }
+
+ public function beginAuthentication(AuthenticationContextInterface $context): bool
+ {
+ return $this->decorated->beginAuthentication($context);
+ }
+
+ public function prepareAuthentication(object $user): void
+ {
+ $this->decorated->prepareAuthentication($user);
+ }
+
+ public function validateAuthenticationCode(object $user, string $authenticationCode): bool
+ {
+ //Try to extract the used webauthn key from the code
+ $webauthnKey = $this->getWebauthnKeyFromCode($authenticationCode);
+
+ //Perform the actual validation like normal
+ $tmp = $this->decorated->validateAuthenticationCode($user, $authenticationCode);
+
+ //Update the last used date of the webauthn key, if the validation was successful
+ if($tmp && $webauthnKey !== null) {
+ $webauthnKey->updateLastTimeUsed();
+ $this->entityManager->flush();
+ }
+
+ return $tmp;
+ }
+
+ public function getFormRenderer(): TwoFactorFormRendererInterface
+ {
+ return $this->decorated->getFormRenderer();
+ }
+
+ private function getWebauthnKeyFromCode(string $authenticationCode): ?WebauthnKey
+ {
+ $publicKeyCredentialLoader = $this->webauthnProvider->getPublicKeyCredentialLoader();
+
+ //Try to load the public key credential from the code
+ $publicKeyCredential = $publicKeyCredentialLoader->load($authenticationCode);
+
+ //Find the credential source for the given credential id
+ $publicKeyCredentialSource = $this->publicKeyCredentialSourceRepository->findOneByCredentialId($publicKeyCredential->rawId);
+
+ //If the credential source is not an instance of WebauthnKey, return null
+ if(!($publicKeyCredentialSource instanceof WebauthnKey)) {
+ return null;
+ }
+
+ return $publicKeyCredentialSource;
+ }
+}
\ No newline at end of file
diff --git a/src/Security/UserChecker.php b/src/Security/UserChecker.php
index d42d3390..16afb37e 100644
--- a/src/Security/UserChecker.php
+++ b/src/Security/UserChecker.php
@@ -25,28 +25,25 @@ namespace App\Security;
use App\Entity\UserSystem\User;
use Symfony\Component\Security\Core\Exception\AccountStatusException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
-use Symfony\Component\Security\Core\Exception\DisabledException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
+/**
+ * @see \App\Tests\Security\UserCheckerTest
+ */
final class UserChecker implements UserCheckerInterface
{
- private TranslatorInterface $translator;
-
- public function __construct(TranslatorInterface $translator)
+ public function __construct(private readonly TranslatorInterface $translator)
{
- $this->translator = $translator;
}
/**
* Checks the user account before authentication.
- *
- * @throws AccountStatusException
*/
public function checkPreAuth(UserInterface $user): void
{
- // TODO: Implement checkPreAuth() method.
+ //We don't need to check the user before authentication, just implemented to fulfill the interface
}
/**
@@ -60,10 +57,10 @@ final class UserChecker implements UserCheckerInterface
return;
}
- //Check if user is disabled. Then dont allow login
+ //Check if user is disabled. Then don't allow login
if ($user->isDisabled()) {
//throw new DisabledException();
- throw new CustomUserMessageAccountStatusException($this->translator->trans('user.login_error.user_disabled'));
+ throw new CustomUserMessageAccountStatusException($this->translator->trans('user.login_error.user_disabled', [], 'security'));
}
}
}
diff --git a/src/Security/Voter/AttachmentVoter.php b/src/Security/Voter/AttachmentVoter.php
index 135ba57f..c2b17053 100644
--- a/src/Security/Voter/AttachmentVoter.php
+++ b/src/Security/Voter/AttachmentVoter.php
@@ -22,57 +22,109 @@ declare(strict_types=1);
namespace App\Security\Voter;
+use App\Services\UserSystem\VoterHelper;
+use Symfony\Bundle\SecurityBundle\Security;
+use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\Attachment;
-use App\Entity\UserSystem\User;
-use App\Services\UserSystem\PermissionManager;
-use Doctrine\ORM\EntityManagerInterface;
-use Symfony\Component\Security\Core\Security;
+use App\Entity\Attachments\AttachmentTypeAttachment;
+use App\Entity\Attachments\CategoryAttachment;
+use App\Entity\Attachments\CurrencyAttachment;
+use App\Entity\Attachments\FootprintAttachment;
+use App\Entity\Attachments\GroupAttachment;
+use App\Entity\Attachments\ManufacturerAttachment;
+use App\Entity\Attachments\MeasurementUnitAttachment;
+use App\Entity\Attachments\PartAttachment;
+use App\Entity\Attachments\ProjectAttachment;
+use App\Entity\Attachments\StorageLocationAttachment;
+use App\Entity\Attachments\SupplierAttachment;
+use App\Entity\Attachments\UserAttachment;
+use RuntimeException;
+
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use function in_array;
-class AttachmentVoter extends ExtendedVoter
+/**
+ * @phpstan-extends Voter
+ */
+final class AttachmentVoter extends Voter
{
- protected $security;
+ private const ALLOWED_ATTRIBUTES = ['read', 'view', 'edit', 'delete', 'create', 'show_private', 'show_history'];
- public function __construct(PermissionManager $resolver, EntityManagerInterface $entityManager, Security $security)
+ public function __construct(private readonly Security $security, private readonly VoterHelper $helper)
{
- parent::__construct($resolver, $entityManager);
- $this->security = $security;
}
- /**
- * Similar to voteOnAttribute, but checking for the anonymous user is already done.
- * The current user (or the anonymous user) is passed by $user.
- *
- * @param string $attribute
- */
- protected function voteOnUser(string $attribute, $subject, User $user): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
- //return $this->resolver->inherit($user, 'attachments', $attribute) ?? false;
- //If the attachment has no element (which should not happen), we deny access, as we can not determine if the user is allowed to access the associated element
- $target_element = $subject->getElement();
- if (! $subject instanceof Attachment || null === $target_element) {
+ //This voter only works for attachments
+ if (!is_a($subject, Attachment::class, true)) {
return false;
}
- //Depending on the operation delegate either to the attachments element or to the attachment permission
- switch ($attribute) {
- //We can view the attachment if we can view the element
- case 'read':
- case 'view':
- return $this->security->isGranted('read', $target_element);
- //We can edit/create/delete the attachment if we can edit the element
- case 'edit':
- case 'create':
- case 'delete':
- return $this->security->isGranted('edit', $target_element);
-
- case 'show_private':
- return $this->resolver->inherit($user, 'attachments', 'show_private') ?? false;
+ if ($attribute === 'show_private') {
+ return $this->helper->isGranted($token, 'attachments', 'show_private');
}
- throw new \RuntimeException('Encountered unknown attribute "'.$attribute.'" in AttachmentVoter!');
+
+ if (is_object($subject)) {
+ //If the attachment has no element (which should not happen), we deny access, as we can not determine if the user is allowed to access the associated element
+ $target_element = $subject->getElement();
+ if ($target_element instanceof AttachmentContainingDBElement) {
+ return $this->security->isGranted($this->mapOperation($attribute), $target_element);
+ }
+ }
+
+ if (is_string($subject)) {
+ //If we do not have a concrete element (or we just got a string as value), we delegate to the different categories
+ if (is_a($subject, AttachmentTypeAttachment::class, true)) {
+ $param = 'attachment_types';
+ } elseif (is_a($subject, CategoryAttachment::class, true)) {
+ $param = 'categories';
+ } elseif (is_a($subject, CurrencyAttachment::class, true)) {
+ $param = 'currencies';
+ } elseif (is_a($subject, ProjectAttachment::class, true)) {
+ $param = 'projects';
+ } elseif (is_a($subject, FootprintAttachment::class, true)) {
+ $param = 'footprints';
+ } elseif (is_a($subject, GroupAttachment::class, true)) {
+ $param = 'groups';
+ } elseif (is_a($subject, ManufacturerAttachment::class, true)) {
+ $param = 'manufacturers';
+ } elseif (is_a($subject, MeasurementUnitAttachment::class, true)) {
+ $param = 'measurement_units';
+ } elseif (is_a($subject, PartAttachment::class, true)) {
+ $param = 'parts';
+ } elseif (is_a($subject, StorageLocationAttachment::class, true)) {
+ $param = 'storelocations';
+ } elseif (is_a($subject, SupplierAttachment::class, true)) {
+ $param = 'suppliers';
+ } elseif (is_a($subject, UserAttachment::class, true)) {
+ $param = 'users';
+ } elseif ($subject === Attachment::class) {
+ //If the subject was deleted, we can not determine the type properly, so we just use the parts permission
+ $param = 'parts';
+ }
+ else {
+ throw new RuntimeException('Encountered unknown Parameter type: ' . $subject);
+ }
+
+ return $this->helper->isGranted($token, $param, $this->mapOperation($attribute));
+ }
+
+ return false;
+ }
+
+ private function mapOperation(string $attribute): string
+ {
+ return match ($attribute) {
+ 'read', 'view' => 'read',
+ 'edit', 'create', 'delete' => 'edit',
+ 'show_history' => 'show_history',
+ default => throw new \RuntimeException('Encountered unknown attribute "'.$attribute.'" in AttachmentVoter!'),
+ };
}
/**
@@ -83,14 +135,24 @@ class AttachmentVoter extends ExtendedVoter
*
* @return bool True if the attribute and subject are supported, false otherwise
*/
- protected function supports(string $attribute, $subject): bool
+ protected function supports(string $attribute, mixed $subject): bool
{
if (is_a($subject, Attachment::class, true)) {
//These are the allowed attributes
- return in_array($attribute, ['read', 'view', 'edit', 'delete', 'create', 'show_private'], true);
+ return in_array($attribute, self::ALLOWED_ATTRIBUTES, true);
}
//Allow class name as subject
return false;
}
+
+ public function supportsAttribute(string $attribute): bool
+ {
+ return in_array($attribute, self::ALLOWED_ATTRIBUTES, true);
+ }
+
+ public function supportsType(string $subjectType): bool
+ {
+ return $subjectType === 'string' || is_a($subjectType, Attachment::class, true);
+ }
}
diff --git a/src/Security/Voter/BOMEntryVoter.php b/src/Security/Voter/BOMEntryVoter.php
new file mode 100644
index 00000000..121c8172
--- /dev/null
+++ b/src/Security/Voter/BOMEntryVoter.php
@@ -0,0 +1,90 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Security\Voter;
+
+use App\Entity\ProjectSystem\Project;
+use App\Entity\ProjectSystem\ProjectBOMEntry;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
+
+/**
+ * @phpstan-extends Voter
+ */
+class BOMEntryVoter extends Voter
+{
+
+ private const ALLOWED_ATTRIBUTES = ['read', 'view', 'edit', 'delete', 'create', 'show_history'];
+
+ public function __construct(private readonly Security $security)
+ {
+ }
+
+ protected function supports(string $attribute, mixed $subject): bool
+ {
+ return $this->supportsAttribute($attribute) && is_a($subject, ProjectBOMEntry::class, true);
+ }
+
+ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
+ {
+ if (!is_a($subject, ProjectBOMEntry::class, true)) {
+ return false;
+ }
+
+ if (is_object($subject)) {
+ $project = $subject->getProject();
+
+ //Allow everything if the project was not set yet
+ if ($project === null) {
+ return true;
+ }
+ } else {
+ //If a string was given, use the general project permissions to resolve permissions
+ $project = Project::class;
+ }
+
+ //Entry can be read if the user has read access to the project
+ if ($attribute === 'read') {
+ return $this->security->isGranted('read', $project);
+ }
+
+ //History can be shown if the user has show_history access to the project
+ if ($attribute === 'show_history') {
+ return $this->security->isGranted('show_history', $project);
+ }
+
+ //Everything else can be done if the user has edit access to the project
+ return $this->security->isGranted('edit', $project);
+ }
+
+ public function supportsAttribute(string $attribute): bool
+ {
+ return in_array($attribute, self::ALLOWED_ATTRIBUTES, true);
+ }
+
+ public function supportsType(string $subjectType): bool
+ {
+ return $subjectType === 'string' || is_a($subjectType, ProjectBOMEntry::class, true);
+ }
+}
\ No newline at end of file
diff --git a/src/Security/Voter/ExtendedVoter.php b/src/Security/Voter/ExtendedVoter.php
deleted file mode 100644
index 825d768c..00000000
--- a/src/Security/Voter/ExtendedVoter.php
+++ /dev/null
@@ -1,75 +0,0 @@
-.
- */
-
-declare(strict_types=1);
-
-namespace App\Security\Voter;
-
-use App\Entity\UserSystem\User;
-use App\Repository\UserRepository;
-use App\Services\UserSystem\PermissionManager;
-use Doctrine\ORM\EntityManagerInterface;
-use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
-use Symfony\Component\Security\Core\Authorization\Voter\Voter;
-
-/**
- * The purpose of this class is, to use the anonymous user from DB in the case, that nobody is logged in.
- */
-abstract class ExtendedVoter extends Voter
-{
- protected EntityManagerInterface $entityManager;
- protected PermissionManager $resolver;
-
- public function __construct(PermissionManager $resolver, EntityManagerInterface $entityManager)
- {
- $this->resolver = $resolver;
- $this->entityManager = $entityManager;
- }
-
- final protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
- {
- $user = $token->getUser();
-
- //An allowed user is not allowed to do anything...
- if ($user instanceof User && $user->isDisabled()) {
- return false;
- }
-
- // if the user is anonymous (meaning $user is null), we use the anonymous user.
- if (!$user instanceof User) {
- /** @var UserRepository $repo */
- $repo = $this->entityManager->getRepository(User::class);
- $user = $repo->getAnonymousUser();
- if (null === $user) {
- return false;
- }
- }
-
- return $this->voteOnUser($attribute, $subject, $user);
- }
-
- /**
- * Similar to voteOnAttribute, but checking for the anonymous user is already done.
- * The current user (or the anonymous user) is passed by $user.
- *
- * @param string $attribute
- */
- abstract protected function voteOnUser(string $attribute, $subject, User $user): bool;
-}
diff --git a/src/Security/Voter/GroupVoter.php b/src/Security/Voter/GroupVoter.php
index e1e21543..34839d38 100644
--- a/src/Security/Voter/GroupVoter.php
+++ b/src/Security/Voter/GroupVoter.php
@@ -23,19 +23,29 @@ declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\UserSystem\Group;
-use App\Entity\UserSystem\User;
+use App\Services\UserSystem\VoterHelper;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
-class GroupVoter extends ExtendedVoter
+/**
+ * @phpstan-extends Voter
+ */
+final class GroupVoter extends Voter
{
+
+ public function __construct(private readonly VoterHelper $helper)
+ {
+ }
+
/**
* Similar to voteOnAttribute, but checking for the anonymous user is already done.
* The current user (or the anonymous user) is passed by $user.
*
* @param string $attribute
*/
- protected function voteOnUser(string $attribute, $subject, User $user): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
- return $this->resolver->inherit($user, 'groups', $attribute) ?? false;
+ return $this->helper->isGranted($token, 'groups', $attribute);
}
/**
@@ -46,12 +56,22 @@ class GroupVoter extends ExtendedVoter
*
* @return bool True if the attribute and subject are supported, false otherwise
*/
- protected function supports(string $attribute, $subject): bool
+ protected function supports(string $attribute, mixed $subject): bool
{
if (is_a($subject, Group::class, true)) {
- return $this->resolver->isValidOperation('groups', $attribute);
+ return $this->helper->isValidOperation('groups', $attribute);
}
return false;
}
+
+ public function supportsAttribute(string $attribute): bool
+ {
+ return $this->helper->isValidOperation('groups', $attribute);
+ }
+
+ public function supportsType(string $subjectType): bool
+ {
+ return $subjectType === 'string' || is_a($subjectType, Group::class, true);
+ }
}
diff --git a/src/Security/Voter/HasAccessPermissionsVoter.php b/src/Security/Voter/HasAccessPermissionsVoter.php
new file mode 100644
index 00000000..bd466d07
--- /dev/null
+++ b/src/Security/Voter/HasAccessPermissionsVoter.php
@@ -0,0 +1,59 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Security\Voter;
+
+use App\Services\UserSystem\PermissionManager;
+use App\Services\UserSystem\VoterHelper;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
+
+/**
+ * This voter implements a virtual role, which can be used if the user has any permission set to allowed.
+ * We use this to restrict access to the homepage.
+ * @phpstan-extends Voter
+ */
+final class HasAccessPermissionsVoter extends Voter
+{
+ public const ROLE = "HAS_ACCESS_PERMISSIONS";
+
+ public function __construct(private readonly PermissionManager $permissionManager, private readonly VoterHelper $helper)
+ {
+ }
+
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
+ {
+ $user = $this->helper->resolveUser($token);
+ return $this->permissionManager->hasAnyPermissionSetToAllowInherited($user);
+ }
+
+ protected function supports(string $attribute, mixed $subject): bool
+ {
+ return $attribute === self::ROLE;
+ }
+
+ public function supportsAttribute(string $attribute): bool
+ {
+ return $attribute === self::ROLE;
+ }
+}
\ No newline at end of file
diff --git a/src/Security/Voter/ImpersonateUserVoter.php b/src/Security/Voter/ImpersonateUserVoter.php
new file mode 100644
index 00000000..edf55c62
--- /dev/null
+++ b/src/Security/Voter/ImpersonateUserVoter.php
@@ -0,0 +1,64 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Security\Voter;
+
+use App\Entity\UserSystem\User;
+use App\Services\UserSystem\VoterHelper;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
+use Symfony\Component\Security\Core\User\UserInterface;
+
+/**
+ * This voter implements a virtual role, which can be used if the user has any permission set to allowed.
+ * We use this to restrict access to the homepage.
+ * @phpstan-extends Voter
+ */
+final class ImpersonateUserVoter extends Voter
+{
+
+ public function __construct(private readonly VoterHelper $helper)
+ {
+ }
+
+ protected function supports(string $attribute, mixed $subject): bool
+ {
+ return $attribute === 'CAN_SWITCH_USER'
+ && $subject instanceof UserInterface;
+ }
+
+ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
+ {
+ return $this->helper->isGranted($token, 'users', 'impersonate');
+ }
+
+ public function supportsAttribute(string $attribute): bool
+ {
+ return $attribute === 'CAN_SWITCH_USER';
+ }
+
+ public function supportsType(string $subjectType): bool
+ {
+ return is_a($subjectType, User::class, true);
+ }
+}
\ No newline at end of file
diff --git a/src/Security/Voter/LabelProfileVoter.php b/src/Security/Voter/LabelProfileVoter.php
index 5a3699a2..47505bf9 100644
--- a/src/Security/Voter/LabelProfileVoter.php
+++ b/src/Security/Voter/LabelProfileVoter.php
@@ -42,9 +42,14 @@ declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\LabelSystem\LabelProfile;
-use App\Entity\UserSystem\User;
+use App\Services\UserSystem\VoterHelper;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
-class LabelProfileVoter extends ExtendedVoter
+/**
+ * @phpstan-extends Voter
+ */
+final class LabelProfileVoter extends Voter
{
protected const MAPPING = [
'read' => 'read_profiles',
@@ -55,21 +60,34 @@ class LabelProfileVoter extends ExtendedVoter
'revert_element' => 'revert_element',
];
- protected function voteOnUser(string $attribute, $subject, User $user): bool
+ public function __construct(private readonly VoterHelper $helper)
+ {}
+
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
- return $this->resolver->inherit($user, 'labels', self::MAPPING[$attribute]) ?? false;
+ return $this->helper->isGranted($token, 'labels', self::MAPPING[$attribute]);
}
protected function supports($attribute, $subject): bool
{
- if ($subject instanceof LabelProfile) {
+ if (is_a($subject, LabelProfile::class, true)) {
if (!isset(self::MAPPING[$attribute])) {
return false;
}
- return $this->resolver->isValidOperation('labels', self::MAPPING[$attribute]);
+ return $this->helper->isValidOperation('labels', self::MAPPING[$attribute]);
}
return false;
}
+
+ public function supportsAttribute(string $attribute): bool
+ {
+ return isset(self::MAPPING[$attribute]);
+ }
+
+ public function supportsType(string $subjectType): bool
+ {
+ return $subjectType === 'string' || is_a($subjectType, LabelProfile::class, true);
+ }
}
diff --git a/src/Security/Voter/LogEntryVoter.php b/src/Security/Voter/LogEntryVoter.php
index b03cd99f..08bc3b70 100644
--- a/src/Security/Voter/LogEntryVoter.php
+++ b/src/Security/Voter/LogEntryVoter.php
@@ -22,29 +22,56 @@ declare(strict_types=1);
namespace App\Security\Voter;
+use App\Services\UserSystem\VoterHelper;
+use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\LogSystem\AbstractLogEntry;
-use App\Entity\UserSystem\User;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
-class LogEntryVoter extends ExtendedVoter
+/**
+ * @phpstan-extends Voter
+ */
+final class LogEntryVoter extends Voter
{
- public const ALLOWED_OPS = ['read', 'delete'];
+ final public const ALLOWED_OPS = ['read', 'show_details', 'delete'];
- protected function voteOnUser(string $attribute, $subject, User $user): bool
+ public function __construct(private readonly Security $security, private readonly VoterHelper $helper)
{
+ }
+
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
+ {
+ $user = $this->helper->resolveUser($token);
+
+ if (!$subject instanceof AbstractLogEntry) {
+ throw new \InvalidArgumentException('The subject must be an instance of '.AbstractLogEntry::class);
+ }
+
if ('delete' === $attribute) {
- return $this->resolver->inherit($user, 'system', 'delete_logs') ?? false;
+ return $this->helper->isGranted($token, 'system', 'delete_logs');
}
if ('read' === $attribute) {
//Allow read of the users own log entries
if (
$subject->getUser() === $user
- && $this->resolver->inherit($user, 'self', 'show_logs')
+ && $this->helper->isGranted($token, 'self', 'show_logs')
) {
return true;
}
- return $this->resolver->inherit($user, 'system', 'show_logs') ?? false;
+ return $this->helper->isGranted($token, 'system', 'show_logs');
+ }
+
+ if ('show_details' === $attribute) {
+ //To view details of a element related log entry, the user needs to be able to view the history of this entity type
+ $targetClass = $subject->getTargetClass();
+ if (null !== $targetClass) {
+ return $this->security->isGranted('show_history', $targetClass);
+ }
+
+ //In other cases, this behaves like the read permission
+ return $this->voteOnAttribute('read', $subject, $token);
}
return false;
@@ -53,9 +80,19 @@ class LogEntryVoter extends ExtendedVoter
protected function supports($attribute, $subject): bool
{
if ($subject instanceof AbstractLogEntry) {
- return in_array($subject, static::ALLOWED_OPS, true);
+ return in_array($attribute, static::ALLOWED_OPS, true);
}
return false;
}
+
+ public function supportsAttribute(string $attribute): bool
+ {
+ return in_array($attribute, static::ALLOWED_OPS, true);
+ }
+
+ public function supportsType(string $subjectType): bool
+ {
+ return is_a($subjectType, AbstractLogEntry::class, true);
+ }
}
diff --git a/src/Security/Voter/OrderdetailVoter.php b/src/Security/Voter/OrderdetailVoter.php
index eaeea11d..20843b9a 100644
--- a/src/Security/Voter/OrderdetailVoter.php
+++ b/src/Security/Voter/OrderdetailVoter.php
@@ -41,52 +41,41 @@ declare(strict_types=1);
namespace App\Security\Voter;
+use App\Services\UserSystem\VoterHelper;
+use Symfony\Bundle\SecurityBundle\Security;
+use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Orderdetail;
-use App\Entity\UserSystem\User;
-use App\Services\UserSystem\PermissionManager;
-use Doctrine\ORM\EntityManagerInterface;
-use Symfony\Component\Security\Core\Security;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
-class OrderdetailVoter extends ExtendedVoter
+/**
+ * @phpstan-extends Voter
+ */
+final class OrderdetailVoter extends Voter
{
- protected Security $security;
-
- public function __construct(PermissionManager $resolver, EntityManagerInterface $entityManager, Security $security)
+ public function __construct(private readonly Security $security, private readonly VoterHelper $helper)
{
- parent::__construct($resolver, $entityManager);
- $this->security = $security;
}
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
- protected function voteOnUser(string $attribute, $subject, User $user): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
if (! is_a($subject, Orderdetail::class, true)) {
throw new \RuntimeException('This voter can only handle Orderdetail objects!');
}
- switch ($attribute) {
- case 'read':
- $operation = 'read';
- break;
- case 'edit': //As long as we can edit, we can also edit orderdetails
- case 'create':
- case 'delete':
- $operation = 'edit';
- break;
- case 'show_history':
- $operation = 'show_history';
- break;
- case 'revert_element':
- $operation = 'revert_element';
- break;
- default:
- throw new \RuntimeException('Encountered unknown operation "'.$attribute.'"!');
- }
+ $operation = match ($attribute) {
+ 'read' => 'read',
+ 'edit', 'create', 'delete' => 'edit',
+ 'show_history' => 'show_history',
+ 'revert_element' => 'revert_element',
+ default => throw new \RuntimeException('Encountered unknown operation "'.$attribute.'"!'),
+ };
//If we have no part associated use the generic part permission
- if (is_string($subject) || $subject->getPart() === null) {
- return $this->resolver->inherit($user, 'parts', $operation) ?? false;
+ if (is_string($subject) || !$subject->getPart() instanceof Part) {
+ return $this->helper->isGranted($token, 'parts', $operation);
}
//Otherwise vote on the part
@@ -101,4 +90,14 @@ class OrderdetailVoter extends ExtendedVoter
return false;
}
+
+ public function supportsAttribute(string $attribute): bool
+ {
+ return in_array($attribute, self::ALLOWED_PERMS, true);
+ }
+
+ public function supportsType(string $subjectType): bool
+ {
+ return $subjectType === 'string' || is_a($subjectType, Orderdetail::class, true);
+ }
}
diff --git a/src/Security/Voter/ParameterVoter.php b/src/Security/Voter/ParameterVoter.php
index 525a75b6..8ee2b9f5 100644
--- a/src/Security/Voter/ParameterVoter.php
+++ b/src/Security/Voter/ParameterVoter.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Security\Voter;
+use App\Services\UserSystem\VoterHelper;
+use Symfony\Bundle\SecurityBundle\Security;
+use App\Entity\Base\AbstractDBElement;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parameters\AttachmentTypeParameter;
use App\Entity\Parameters\CategoryParameter;
@@ -30,102 +35,101 @@ use App\Entity\Parameters\GroupParameter;
use App\Entity\Parameters\ManufacturerParameter;
use App\Entity\Parameters\MeasurementUnitParameter;
use App\Entity\Parameters\PartParameter;
-use App\Entity\Parameters\StorelocationParameter;
+use App\Entity\Parameters\StorageLocationParameter;
use App\Entity\Parameters\SupplierParameter;
-use App\Entity\UserSystem\User;
-use App\Services\UserSystem\PermissionManager;
-use Doctrine\ORM\EntityManagerInterface;
use RuntimeException;
-use Symfony\Component\Security\Core\Security;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
-class ParameterVoter extends ExtendedVoter
+/**
+ * @phpstan-extends Voter
+ */
+final class ParameterVoter extends Voter
{
- protected Security $security;
+ private const ALLOWED_ATTRIBUTES = ['read', 'edit', 'delete', 'create', 'show_history', 'revert_element'];
- public function __construct(PermissionManager $resolver, EntityManagerInterface $entityManager, Security $security)
+ public function __construct(private readonly Security $security, private readonly VoterHelper $helper)
{
- $this->security = $security;
- parent::__construct($resolver, $entityManager);
}
- protected function voteOnUser(string $attribute, $subject, User $user): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
//return $this->resolver->inherit($user, 'attachments', $attribute) ?? false;
- if (!$subject instanceof AbstractParameter) {
+ if (!is_a($subject, AbstractParameter::class, true)) {
return false;
}
- //If the attachment has no element (which should not happen), we deny access, as we can not determine if the user is allowed to access the associated element
- $target_element = $subject->getElement();
- if ($target_element !== null) {
- //Depending on the operation delegate either to the attachments element or to the attachment permission
+ if (is_object($subject)) {
+ //If the attachment has no element (which should not happen), we deny access, as we can not determine if the user is allowed to access the associated element
+ $target_element = $subject->getElement();
+ if ($target_element instanceof AbstractDBElement) {
+ $operation = match ($attribute) {
+ 'read', 'view' => 'read',
+ 'edit', 'create', 'delete' => 'edit',
+ 'show_history' => 'show_history',
+ 'revert_element' => 'revert_element',
+ default => throw new RuntimeException('Unknown operation: '.$attribute),
+ };
-
- switch ($attribute) {
- //We can view the attachment if we can view the element
- case 'read':
- case 'view':
- $operation = 'read';
- break;
- //We can edit/create/delete the attachment if we can edit the element
- case 'edit':
- case 'create':
- case 'delete':
- $operation = 'edit';
- break;
- case 'show_history':
- $operation = 'show_history';
- break;
- case 'revert_element':
- $operation = 'revert_element';
- break;
- default:
- throw new RuntimeException('Unknown operation: '.$attribute);
+ return $this->security->isGranted($operation, $target_element);
}
-
- return $this->security->isGranted($operation, $target_element);
}
- //If we do not have a concrete element, we delegate to the different categories
- if ($subject instanceof AttachmentTypeParameter) {
+ //If we do not have a concrete element (or we just got a string as value), we delegate to the different categories
+ if (is_a($subject, AttachmentTypeParameter::class, true)) {
$param = 'attachment_types';
- } elseif ($subject instanceof CategoryParameter) {
+ } elseif (is_a($subject, CategoryParameter::class, true)) {
$param = 'categories';
- } elseif ($subject instanceof CurrencyParameter) {
+ } elseif (is_a($subject, CurrencyParameter::class, true)) {
$param = 'currencies';
- } elseif ($subject instanceof ProjectParameter) {
+ } elseif (is_a($subject, ProjectParameter::class, true)) {
$param = 'projects';
- } elseif ($subject instanceof FootprintParameter) {
+ } elseif (is_a($subject, FootprintParameter::class, true)) {
$param = 'footprints';
- } elseif ($subject instanceof GroupParameter) {
+ } elseif (is_a($subject, GroupParameter::class, true)) {
$param = 'groups';
- } elseif ($subject instanceof ManufacturerParameter) {
+ } elseif (is_a($subject, ManufacturerParameter::class, true)) {
$param = 'manufacturers';
- } elseif ($subject instanceof MeasurementUnitParameter) {
+ } elseif (is_a($subject, MeasurementUnitParameter::class, true)) {
$param = 'measurement_units';
- } elseif ($subject instanceof PartParameter) {
+ } elseif (is_a($subject, PartParameter::class, true)) {
$param = 'parts';
- } elseif ($subject instanceof StorelocationParameter) {
+ } elseif (is_a($subject, StorageLocationParameter::class, true)) {
$param = 'storelocations';
- } elseif ($subject instanceof SupplierParameter) {
+ } elseif (is_a($subject, SupplierParameter::class, true)) {
$param = 'suppliers';
- } else {
- throw new RuntimeException('Encountered unknown Parameter type: ' . get_class($subject));
+ } elseif ($subject === AbstractParameter::class) {
+ //If the subject was deleted, we can not determine the type properly, so we just use the parts permission
+ $param = 'parts';
+ }
+ else {
+ throw new RuntimeException('Encountered unknown Parameter type: ' . (is_object($subject) ? $subject::class : $subject));
}
- return $this->resolver->inherit($user, $param, $attribute) ?? false;
+ return $this->helper->isGranted($token, $param, $attribute);
}
- protected function supports(string $attribute, $subject)
+ protected function supports(string $attribute, $subject): bool
{
if (is_a($subject, AbstractParameter::class, true)) {
//These are the allowed attributes
- return in_array($attribute, ['read', 'edit', 'delete', 'create', 'show_history', 'revert_element'], true);
+ return in_array($attribute, self::ALLOWED_ATTRIBUTES, true);
}
//Allow class name as subject
return false;
}
-}
\ No newline at end of file
+
+ public function supportsAttribute(string $attribute): bool
+ {
+ return in_array($attribute, self::ALLOWED_ATTRIBUTES, true);
+ }
+
+ public function supportsType(string $subjectType): bool
+ {
+ return $subjectType === 'string' || is_a($subjectType, AbstractParameter::class, true);
+ }
+
+}
diff --git a/src/Security/Voter/PartAssociationVoter.php b/src/Security/Voter/PartAssociationVoter.php
new file mode 100644
index 00000000..7678b67a
--- /dev/null
+++ b/src/Security/Voter/PartAssociationVoter.php
@@ -0,0 +1,105 @@
+.
+ */
+
+declare(strict_types=1);
+
+/**
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+namespace App\Security\Voter;
+
+use App\Entity\Parts\PartAssociation;
+use App\Services\UserSystem\VoterHelper;
+use Symfony\Bundle\SecurityBundle\Security;
+use App\Entity\Parts\Part;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
+
+/**
+ * This voter handles permissions for part associations.
+ * The permissions are inherited from the part.
+ * @phpstan-extends Voter
+ */
+final class PartAssociationVoter extends Voter
+{
+ public function __construct(private readonly Security $security, private readonly VoterHelper $helper)
+ {
+ }
+
+ protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
+
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
+ {
+ if (!is_string($subject) && !$subject instanceof PartAssociation) {
+ throw new \RuntimeException('Invalid subject type!');
+ }
+
+ $operation = match ($attribute) {
+ 'read' => 'read',
+ 'edit', 'create', 'delete' => 'edit',
+ 'show_history' => 'show_history',
+ 'revert_element' => 'revert_element',
+ default => throw new \RuntimeException('Encountered unknown operation "'.$attribute.'"!'),
+ };
+
+ //If we have no part associated use the generic part permission
+ if (is_string($subject) || !$subject->getOwner() instanceof Part) {
+ return $this->helper->isGranted($token, 'parts', $operation);
+ }
+
+ //Otherwise vote on the part
+ return $this->security->isGranted($attribute, $subject->getOwner());
+ }
+
+ protected function supports($attribute, $subject): bool
+ {
+ if (is_a($subject, PartAssociation::class, true)) {
+ return in_array($attribute, self::ALLOWED_PERMS, true);
+ }
+
+ return false;
+ }
+
+ public function supportsType(string $subjectType): bool
+ {
+ return $subjectType === 'string' || is_a($subjectType, PartAssociation::class, true);
+ }
+
+ public function supportsAttribute(string $attribute): bool
+ {
+ return in_array($attribute, self::ALLOWED_PERMS, true);
+ }
+}
diff --git a/src/Security/Voter/PartLotVoter.php b/src/Security/Voter/PartLotVoter.php
index da05070b..a64473c8 100644
--- a/src/Security/Voter/PartLotVoter.php
+++ b/src/Security/Voter/PartLotVoter.php
@@ -41,57 +41,52 @@ declare(strict_types=1);
namespace App\Security\Voter;
+use App\Services\UserSystem\VoterHelper;
+use Symfony\Bundle\SecurityBundle\Security;
+use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
-use App\Entity\UserSystem\User;
-use App\Services\UserSystem\PermissionManager;
-use Doctrine\ORM\EntityManagerInterface;
-use Symfony\Component\Security\Core\Security;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
-class PartLotVoter extends ExtendedVoter
+/**
+ * @phpstan-extends Voter
+ */
+final class PartLotVoter extends Voter
{
- protected Security $security;
-
- public function __construct(PermissionManager $resolver, EntityManagerInterface $entityManager, Security $security)
+ public function __construct(private readonly Security $security, private readonly VoterHelper $helper)
{
- parent::__construct($resolver, $entityManager);
- $this->security = $security;
}
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element', 'withdraw', 'add', 'move'];
- protected function voteOnUser(string $attribute, $subject, User $user): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
- if (! is_a($subject, PartLot::class, true)) {
- throw new \RuntimeException('This voter can only handle PartLot objects!');
- }
+ $user = $this->helper->resolveUser($token);
- if (in_array($attribute, ['withdraw', 'add', 'move']))
+ if (in_array($attribute, ['withdraw', 'add', 'move'], true))
{
- return $this->resolver->inherit($user, 'parts_stock', $attribute) ?? false;
+ $base_permission = $this->helper->isGranted($token, 'parts_stock', $attribute);
+
+ $lot_permission = true;
+ //If the lot has an owner, we need to check if the user is the owner of the lot to be allowed to withdraw it.
+ if ($subject instanceof PartLot && $subject->getOwner()) {
+ $lot_permission = $subject->getOwner() === $user || $subject->getOwner()->getID() === $user->getID();
+ }
+
+ return $base_permission && $lot_permission;
}
- switch ($attribute) {
- case 'read':
- $operation = 'read';
- break;
- case 'edit': //As long as we can edit, we can also edit orderdetails
- case 'create':
- case 'delete':
- $operation = 'edit';
- break;
- case 'show_history':
- $operation = 'show_history';
- break;
- case 'revert_element':
- $operation = 'revert_element';
- break;
- default:
- throw new \RuntimeException('Encountered unknown operation "'.$attribute.'"!');
- }
+ $operation = match ($attribute) {
+ 'read' => 'read',
+ 'edit', 'create', 'delete' => 'edit',
+ 'show_history' => 'show_history',
+ 'revert_element' => 'revert_element',
+ default => throw new \RuntimeException('Encountered unknown operation "'.$attribute.'"!'),
+ };
//If we have no part associated use the generic part permission
- if (is_string($subject) || $subject->getPart() === null) {
- return $this->resolver->inherit($user, 'parts', $operation) ?? false;
+ if (is_string($subject) || !$subject->getPart() instanceof Part) {
+ return $this->helper->isGranted($token, 'parts', $operation);
}
//Otherwise vote on the part
@@ -106,4 +101,14 @@ class PartLotVoter extends ExtendedVoter
return false;
}
+
+ public function supportsAttribute(string $attribute): bool
+ {
+ return in_array($attribute, self::ALLOWED_PERMS, true);
+ }
+
+ public function supportsType(string $subjectType): bool
+ {
+ return $subjectType === 'string' || is_a($subjectType, PartLot::class, true);
+ }
}
diff --git a/src/Security/Voter/PartVoter.php b/src/Security/Voter/PartVoter.php
index fb1e3a38..ef70b6ce 100644
--- a/src/Security/Voter/PartVoter.php
+++ b/src/Security/Voter/PartVoter.php
@@ -23,30 +23,48 @@ declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Parts\Part;
-use App\Entity\UserSystem\User;
+use App\Services\UserSystem\VoterHelper;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* A Voter that votes on Part entities.
*
* See parts permissions for valid operations.
+ *
+ * @phpstan-extends Voter
*/
-class PartVoter extends ExtendedVoter
+final class PartVoter extends Voter
{
- public const READ = 'read';
+ final public const READ = 'read';
+
+ public function __construct(private readonly VoterHelper $helper)
+ {
+ }
protected function supports($attribute, $subject): bool
{
if (is_a($subject, Part::class, true)) {
- return $this->resolver->isValidOperation('parts', $attribute);
+ return $this->helper->isValidOperation('parts', $attribute);
}
//Allow class name as subject
return false;
}
- protected function voteOnUser(string $attribute, $subject, User $user): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
//Null concealing operator means, that no
- return $this->resolver->inherit($user, 'parts', $attribute) ?? false;
+ return $this->helper->isGranted($token, 'parts', $attribute);
+ }
+
+ public function supportsAttribute(string $attribute): bool
+ {
+ return $this->helper->isValidOperation('parts', $attribute);
+ }
+
+ public function supportsType(string $subjectType): bool
+ {
+ return $subjectType === 'string' || is_a($subjectType, Part::class, true);
}
}
diff --git a/src/Security/Voter/PermissionVoter.php b/src/Security/Voter/PermissionVoter.php
index 018c4f92..c6ec1b3d 100644
--- a/src/Security/Voter/PermissionVoter.php
+++ b/src/Security/Voter/PermissionVoter.php
@@ -22,27 +22,35 @@ declare(strict_types=1);
namespace App\Security\Voter;
-use App\Entity\UserSystem\User;
+use App\Services\UserSystem\VoterHelper;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* This voter allows you to directly check permissions from the permission structure, without passing an object.
* This use the syntax like "@permission.op"
* However you should use the "normal" object based voters if possible, because they are needed for a future ACL system.
+ * @phpstan-extends Voter
*/
-class PermissionVoter extends ExtendedVoter
+final class PermissionVoter extends Voter
{
- /**
- * Similar to voteOnAttribute, but checking for the anonymous user is already done.
- * The current user (or the anonymous user) is passed by $user.
- *
- * @param string $attribute
- */
- protected function voteOnUser(string $attribute, $subject, User $user): bool
+ public function __construct(private readonly VoterHelper $helper)
+ {
+
+ }
+
+ protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$attribute = ltrim($attribute, '@');
[$perm, $op] = explode('.', $attribute);
- return $this->resolver->inherit($user, $perm, $op) ?? false;
+ return $this->helper->isGranted($token, $perm, $op);
+ }
+
+ public function supportsAttribute(string $attribute): bool
+ {
+ //Check if the attribute has the form '@permission.operation'
+ return preg_match('#^@\\w+\\.\\w+$#', $attribute) === 1;
}
/**
@@ -53,14 +61,14 @@ class PermissionVoter extends ExtendedVoter
*
* @return bool True if the attribute and subject are supported, false otherwise
*/
- protected function supports(string $attribute, $subject): bool
+ protected function supports(string $attribute, mixed $subject): bool
{
//Check if the attribute has the form @permission.operation
if (preg_match('#^@\\w+\\.\\w+$#', $attribute)) {
$attribute = ltrim($attribute, '@');
[$perm, $op] = explode('.', $attribute);
- $valid = $this->resolver->isValidOperation($perm, $op);
+ $valid = $this->helper->isValidOperation($perm, $op);
//if an invalid operation is encountered, throw an exception so the developer knows it
if(!$valid) {
diff --git a/src/Security/Voter/PricedetailVoter.php b/src/Security/Voter/PricedetailVoter.php
index eb4a81aa..681b73b7 100644
--- a/src/Security/Voter/PricedetailVoter.php
+++ b/src/Security/Voter/PricedetailVoter.php
@@ -41,52 +41,38 @@ declare(strict_types=1);
namespace App\Security\Voter;
+use App\Services\UserSystem\VoterHelper;
+use Symfony\Bundle\SecurityBundle\Security;
+use App\Entity\PriceInformations\Orderdetail;
+use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Pricedetail;
-use App\Entity\UserSystem\User;
-use App\Services\UserSystem\PermissionManager;
-use Doctrine\ORM\EntityManagerInterface;
-use Symfony\Component\Security\Core\Security;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
-class PricedetailVoter extends ExtendedVoter
+/**
+ * @phpstan-extends Voter
+ */
+final class PricedetailVoter extends Voter
{
- protected Security $security;
-
- public function __construct(PermissionManager $resolver, EntityManagerInterface $entityManager, Security $security)
+ public function __construct(private readonly Security $security, private readonly VoterHelper $helper)
{
- parent::__construct($resolver, $entityManager);
- $this->security = $security;
}
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
- protected function voteOnUser(string $attribute, $subject, User $user): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
- if (!is_a($subject, Pricedetail::class, true)) {
- throw new \RuntimeException('This voter can only handle Pricedetails objects!');
- }
-
- switch ($attribute) {
- case 'read':
- $operation = 'read';
- break;
- case 'edit': //As long as we can edit, we can also edit orderdetails
- case 'create':
- case 'delete':
- $operation = 'edit';
- break;
- case 'show_history':
- $operation = 'show_history';
- break;
- case 'revert_element':
- $operation = 'revert_element';
- break;
- default:
- throw new \RuntimeException('Encountered unknown operation "'.$attribute.'"!');
- }
+ $operation = match ($attribute) {
+ 'read' => 'read',
+ 'edit', 'create', 'delete' => 'edit',
+ 'show_history' => 'show_history',
+ 'revert_element' => 'revert_element',
+ default => throw new \RuntimeException('Encountered unknown operation "'.$attribute.'"!'),
+ };
//If we have no part associated use the generic part permission
- if (is_string($subject) || $subject->getOrderdetail() === null || $subject->getOrderdetail()->getPart() === null) {
- return $this->resolver->inherit($user, 'parts', $operation) ?? false;
+ if (is_string($subject) || !$subject->getOrderdetail() instanceof Orderdetail || !$subject->getOrderdetail()->getPart() instanceof Part) {
+ return $this->helper->isGranted($token, 'parts', $operation);
}
//Otherwise vote on the part
@@ -101,4 +87,14 @@ class PricedetailVoter extends ExtendedVoter
return false;
}
+
+ public function supportsType(string $subjectType): bool
+ {
+ return $subjectType === 'string' || is_a($subjectType, Pricedetail::class, true);
+ }
+
+ public function supportsAttribute(string $attribute): bool
+ {
+ return in_array($attribute, self::ALLOWED_PERMS, true);
+ }
}
diff --git a/src/Security/Voter/StructureVoter.php b/src/Security/Voter/StructureVoter.php
index df88e113..2417b796 100644
--- a/src/Security/Voter/StructureVoter.php
+++ b/src/Security/Voter/StructureVoter.php
@@ -28,14 +28,19 @@ use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
-use App\Entity\Parts\Storelocation;
+use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
-use App\Entity\UserSystem\User;
-use function get_class;
+use App\Services\UserSystem\VoterHelper;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
+
use function is_object;
-class StructureVoter extends ExtendedVoter
+/**
+ * @phpstan-extends Voter
+ */
+final class StructureVoter extends Voter
{
protected const OBJ_PERM_MAP = [
AttachmentType::class => 'attachment_types',
@@ -43,12 +48,16 @@ class StructureVoter extends ExtendedVoter
Project::class => 'projects',
Footprint::class => 'footprints',
Manufacturer::class => 'manufacturers',
- Storelocation::class => 'storelocations',
+ StorageLocation::class => 'storelocations',
Supplier::class => 'suppliers',
Currency::class => 'currencies',
MeasurementUnit::class => 'measurement_units',
];
+ public function __construct(private readonly VoterHelper $helper)
+ {
+ }
+
/**
* Determines if the attribute and subject are supported by this voter.
*
@@ -57,31 +66,32 @@ class StructureVoter extends ExtendedVoter
*
* @return bool True if the attribute and subject are supported, false otherwise
*/
- protected function supports(string $attribute, $subject): bool
+ protected function supports(string $attribute, mixed $subject): bool
{
if (is_object($subject) || is_string($subject)) {
$permission_name = $this->instanceToPermissionName($subject);
//If permission name is null, then the subject is not supported
- return (null !== $permission_name) && $this->resolver->isValidOperation($permission_name, $attribute);
+ return (null !== $permission_name) && $this->helper->isValidOperation($permission_name, $attribute);
}
return false;
}
+ public function supportsType(string $subjectType): bool
+ {
+ return $subjectType === 'string' || $this->instanceToPermissionName($subjectType) !== null;
+ }
+
/**
- * Maps a instance type to the permission name.
+ * Maps an instance type to the permission name.
*
- * @param object|string $subject The subject for which the permission name should be generated
+ * @param object|string $subject The subject for which the permission name should be generated
*
* @return string|null the name of the permission for the subject's type or null, if the subject is not supported
*/
- protected function instanceToPermissionName($subject): ?string
+ protected function instanceToPermissionName(object|string $subject): ?string
{
- if (!is_string($subject)) {
- $class_name = get_class($subject);
- } else {
- $class_name = $subject;
- }
+ $class_name = is_string($subject) ? $subject : $subject::class;
//If it is existing in index, we can skip the loop
if (isset(static::OBJ_PERM_MAP[$class_name])) {
@@ -103,10 +113,10 @@ class StructureVoter extends ExtendedVoter
*
* @param string $attribute
*/
- protected function voteOnUser(string $attribute, $subject, User $user): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
$permission_name = $this->instanceToPermissionName($subject);
//Just resolve the permission
- return $this->resolver->inherit($user, $permission_name, $attribute) ?? false;
+ return $this->helper->isGranted($token, $permission_name, $attribute);
}
}
diff --git a/src/Security/Voter/UserVoter.php b/src/Security/Voter/UserVoter.php
index dcd7cb20..b41c1a40 100644
--- a/src/Security/Voter/UserVoter.php
+++ b/src/Security/Voter/UserVoter.php
@@ -23,10 +23,22 @@ declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\UserSystem\User;
+use App\Services\UserSystem\PermissionManager;
+use App\Services\UserSystem\VoterHelper;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+use Symfony\Component\Security\Core\Authorization\Voter\Voter;
+
use function in_array;
-class UserVoter extends ExtendedVoter
+/**
+ * @phpstan-extends Voter
+ */
+final class UserVoter extends Voter
{
+ public function __construct(private readonly VoterHelper $helper, private readonly PermissionManager $resolver)
+ {
+ }
+
/**
* Determines if the attribute and subject are supported by this voter.
*
@@ -35,41 +47,66 @@ class UserVoter extends ExtendedVoter
*
* @return bool True if the attribute and subject are supported, false otherwise
*/
- protected function supports(string $attribute, $subject): bool
+ protected function supports(string $attribute, mixed $subject): bool
{
if (is_a($subject, User::class, true)) {
- return in_array($attribute, array_merge(
- $this->resolver->listOperationsForPermission('users'),
- $this->resolver->listOperationsForPermission('self')),
- false
+ return in_array($attribute,
+ array_merge(
+ $this->resolver->listOperationsForPermission('users'),
+ $this->resolver->listOperationsForPermission('self'),
+ ['info']
+ ),
+ true
);
}
return false;
}
+ public function supportsAttribute(string $attribute): bool
+ {
+ return $this->helper->isValidOperation('users', $attribute) || $this->helper->isValidOperation('self', $attribute) || $attribute === 'info';
+ }
+
+ public function supportsType(string $subjectType): bool
+ {
+ return $subjectType === 'string' || is_a($subjectType, User::class, true);
+ }
+
/**
* Similar to voteOnAttribute, but checking for the anonymous user is already done.
* The current user (or the anonymous user) is passed by $user.
*
* @param string $attribute
*/
- protected function voteOnUser(string $attribute, $subject, User $user): bool
+ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
+ $user = $this->helper->resolveUser($token);
+
+ if ($attribute === 'info') {
+ //Every logged-in user (non-anonymous) can see the info pages of other users
+ if (!$user->isAnonymousUser()) {
+ return true;
+ }
+
+ //For the anonymous user, use the user read permission
+ $attribute = 'read';
+ }
+
//Check if the checked user is the user itself
if (($subject instanceof User) && $subject->getID() === $user->getID() &&
- $this->resolver->isValidOperation('self', $attribute)) {
+ $this->helper->isValidOperation('self', $attribute)) {
//Then we also need to check the self permission
- $tmp = $this->resolver->inherit($user, 'self', $attribute) ?? false;
+ $tmp = $this->helper->isGranted($token, 'self', $attribute);
//But if the self value is not allowed then use just the user value:
if ($tmp) {
return $tmp;
}
}
- //Else just check users permission:
- if ($this->resolver->isValidOperation('users', $attribute)) {
- return $this->resolver->inherit($user, 'users', $attribute) ?? false;
+ //Else just check user permission:
+ if ($this->helper->isValidOperation('users', $attribute)) {
+ return $this->helper->isGranted($token, 'users', $attribute);
}
return false;
diff --git a/src/Serializer/APIPlatform/DetermineTypeFromElementIRIDenormalizer.php b/src/Serializer/APIPlatform/DetermineTypeFromElementIRIDenormalizer.php
new file mode 100644
index 00000000..8283dbbe
--- /dev/null
+++ b/src/Serializer/APIPlatform/DetermineTypeFromElementIRIDenormalizer.php
@@ -0,0 +1,124 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Serializer\APIPlatform;
+
+use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException;
+use ApiPlatform\Api\IriConverterInterface;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
+use App\Entity\Attachments\Attachment;
+use App\Entity\Parameters\AbstractParameter;
+use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
+use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * The purpose of this normalizer is to automatically add the _type discriminator field for the Attachment and AbstractParameter classes
+ * based on the element IRI.
+ * So that for a request pointing for a part element, an PartAttachment is automatically created.
+ * This highly improves UX and is the expected behavior.
+ */
+class DetermineTypeFromElementIRIDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface
+{
+ private const SUPPORTED_CLASSES = [
+ Attachment::class,
+ AbstractParameter::class
+ ];
+
+ use DenormalizerAwareTrait;
+
+ private const ALREADY_CALLED = self::class . '::ALREADY_CALLED';
+
+ public function __construct(private readonly IriConverterInterface $iriConverter, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory)
+ {
+ }
+
+ /**
+ * This functions add the _type discriminator to the input array if necessary automatically from the given element IRI.
+ * @param array $input
+ * @param Operation $operation
+ * @return array
+ * @throws ResourceClassNotFoundException
+ */
+ private function addTypeDiscriminatorIfNecessary(array $input, Operation $operation): array
+ {
+
+ //We only want to modify POST requests
+ if (!$operation instanceof Post) {
+ return $input;
+ }
+
+
+ //Ignore if the _type variable is already set
+ if (isset($input['_type'])) {
+ return $input;
+ }
+
+ if (!isset($input['element']) || !is_string($input['element'])) {
+ return $input;
+ }
+
+ //Retrieve the element
+ $element = $this->iriConverter->getResourceFromIri($input['element']);
+
+ //Retrieve the short name of the operation
+ $type = $this->resourceMetadataCollectionFactory->create($element::class)->getOperation()->getShortName();
+ $input['_type'] = $type;
+
+ return $input;
+ }
+
+ public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): object
+ {
+ //If we are on API platform, we want to add the type discriminator if necessary
+ if (!isset($data['_type']) && isset($context['operation'])) {
+ $data = $this->addTypeDiscriminatorIfNecessary($data, $context['operation']);
+ }
+
+ $context[self::ALREADY_CALLED] = true;
+
+ return $this->denormalizer->denormalize($data, $type, $format, $context);
+ }
+
+ public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
+ {
+ //Only denormalize if the _type discriminator is not set and the class is supported and we not have already called this function
+ return !isset($context[self::ALREADY_CALLED])
+ && is_array($data)
+ && !isset($data['_type'])
+ && in_array($type, self::SUPPORTED_CLASSES, true);
+ }
+
+ public function getSupportedTypes(?string $format): array
+ {
+ $tmp = [];
+
+ foreach (self::SUPPORTED_CLASSES as $class) {
+ $tmp[$class] = false;
+ }
+
+ return $tmp;
+ }
+}
\ No newline at end of file
diff --git a/src/Serializer/APIPlatform/OverrideClassDenormalizer.php b/src/Serializer/APIPlatform/OverrideClassDenormalizer.php
new file mode 100644
index 00000000..c8155abc
--- /dev/null
+++ b/src/Serializer/APIPlatform/OverrideClassDenormalizer.php
@@ -0,0 +1,61 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Serializer\APIPlatform;
+
+use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
+use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * The idea of this denormalizer is to allow to override the type of the object created using a certain context key.
+ * This is required to resolve the issue of the serializer/API platform not correctly being able to determine the type
+ * of the "element" properties of the Attachment and Parameter subclasses.
+ */
+class OverrideClassDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface
+{
+ use DenormalizerAwareTrait;
+
+ public const CONTEXT_KEY = '__override_type__';
+
+ public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
+ {
+ //Deserialize the data with the overridden type
+ $overrideType = $context[self::CONTEXT_KEY];
+ unset($context[self::CONTEXT_KEY]);
+
+ return $this->denormalizer->denormalize($data, $overrideType, $format, $context);
+ }
+
+ public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
+ {
+ return isset($context[self::CONTEXT_KEY]);
+ }
+
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ '*' => false,
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/Serializer/APIPlatform/SkippableItemNormalizer.php b/src/Serializer/APIPlatform/SkippableItemNormalizer.php
new file mode 100644
index 00000000..5568c4cb
--- /dev/null
+++ b/src/Serializer/APIPlatform/SkippableItemNormalizer.php
@@ -0,0 +1,90 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Serializer\APIPlatform;
+
+use ApiPlatform\Serializer\ItemNormalizer;
+use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
+use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
+use Symfony\Component\Serializer\SerializerAwareInterface;
+use Symfony\Component\Serializer\SerializerInterface;
+
+/**
+ * This class decorates API Platform's ItemNormalizer to allow skipping the normalization process by setting the
+ * DISABLE_ITEM_NORMALIZER context key to true. This is useful for all kind of serialization operations, where the API
+ * Platform subsystem should not be used.
+ */
+#[AsDecorator("api_platform.serializer.normalizer.item")]
+class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface
+{
+
+ public const DISABLE_ITEM_NORMALIZER = 'DISABLE_ITEM_NORMALIZER';
+
+ public function __construct(private readonly ItemNormalizer $inner)
+ {
+
+ }
+
+ public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
+ {
+ return $this->inner->denormalize($data, $type, $format, $context);
+ }
+
+ public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
+ {
+ if ($context[self::DISABLE_ITEM_NORMALIZER] ?? false) {
+ return false;
+ }
+
+ return $this->inner->supportsDenormalization($data, $type, $format, $context);
+ }
+
+ public function normalize(mixed $object, ?string $format = null, array $context = []): float|int|bool|\ArrayObject|array|string|null
+ {
+ return $this->inner->normalize($object, $format, $context);
+ }
+
+ public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
+ {
+ if ($context[self::DISABLE_ITEM_NORMALIZER] ?? false) {
+ return false;
+ }
+
+ return $this->inner->supportsNormalization($data, $format, $context);
+ }
+
+ public function setSerializer(SerializerInterface $serializer): void
+ {
+ $this->inner->setSerializer($serializer);
+ }
+
+ public function getSupportedTypes(?string $format): array
+ {
+ //Don't cache results, as we check for the context
+ return [
+ 'object' => false
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/Serializer/AttachmentNormalizer.php b/src/Serializer/AttachmentNormalizer.php
new file mode 100644
index 00000000..bd791d04
--- /dev/null
+++ b/src/Serializer/AttachmentNormalizer.php
@@ -0,0 +1,84 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Serializer;
+
+use App\Entity\Attachments\Attachment;
+use App\Services\Attachments\AttachmentURLGenerator;
+use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
+use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
+
+class AttachmentNormalizer implements NormalizerInterface, NormalizerAwareInterface
+{
+
+ use NormalizerAwareTrait;
+
+ private const ALREADY_CALLED = 'ATTACHMENT_NORMALIZER_ALREADY_CALLED';
+
+ public function __construct(
+ private readonly AttachmentURLGenerator $attachmentURLGenerator,
+ )
+ {
+ }
+
+ public function normalize(mixed $object, ?string $format = null, array $context = []): array|null
+ {
+ if (!$object instanceof Attachment) {
+ throw new \InvalidArgumentException('This normalizer only supports Attachment objects!');
+ }
+
+ //Prevent loops, by adding a flag to the context
+ $context[self::ALREADY_CALLED] = true;
+
+ $data = $this->normalizer->normalize($object, $format, $context);
+ $data['internal_path'] = $this->attachmentURLGenerator->getInternalViewURL($object);
+
+ //Add thumbnail url if the attachment is a picture
+ $data['thumbnail_url'] = $object->isPicture() ? $this->attachmentURLGenerator->getThumbnailURL($object) : null;
+
+ //For backwards compatibility reasons
+ //Deprecated: Use internal_path and external_path instead
+ $data['media_url'] = $data['internal_path'] ?? $object->getExternalPath();
+
+ return $data;
+ }
+
+ public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
+ {
+ // avoid recursion: only call once per object
+ if (isset($context[self::ALREADY_CALLED])) {
+ return false;
+ }
+
+ return $data instanceof Attachment;
+ }
+
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ //We depend on the context to determine if we should normalize or not
+ Attachment::class => false,
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/Serializer/BigNumberNormalizer.php b/src/Serializer/BigNumberNormalizer.php
new file mode 100644
index 00000000..10cedfa5
--- /dev/null
+++ b/src/Serializer/BigNumberNormalizer.php
@@ -0,0 +1,75 @@
+.
+ */
+namespace App\Serializer;
+
+use Brick\Math\BigDecimal;
+use Brick\Math\BigNumber;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
+
+/**
+ * @see \App\Tests\Serializer\BigNumberNormalizerTest
+ */
+class BigNumberNormalizer implements NormalizerInterface, DenormalizerInterface
+{
+
+ public function supportsNormalization($data, ?string $format = null, array $context = []): bool
+ {
+ return $data instanceof BigNumber;
+ }
+
+ public function normalize($object, ?string $format = null, array $context = []): string
+ {
+ if (!$object instanceof BigNumber) {
+ throw new \InvalidArgumentException('This normalizer only supports BigNumber objects!');
+ }
+
+ return (string) $object;
+ }
+
+ /**
+ * @return bool[]
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ BigNumber::class => true,
+ BigDecimal::class => true,
+ ];
+ }
+
+ public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): BigNumber|null
+ {
+ if (!is_a($type, BigNumber::class, true)) {
+ throw new \InvalidArgumentException('This normalizer only supports BigNumber objects!');
+ }
+
+ return $type::of($data);
+ }
+
+ public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
+ {
+ //data must be a string or a number (int, float, etc.) and the type must be BigNumber or BigDecimal
+ return (is_string($data) || is_numeric($data)) && (is_subclass_of($type, BigNumber::class));
+ }
+}
diff --git a/src/Serializer/PartNormalizer.php b/src/Serializer/PartNormalizer.php
new file mode 100644
index 00000000..9050abfc
--- /dev/null
+++ b/src/Serializer/PartNormalizer.php
@@ -0,0 +1,206 @@
+.
+ */
+namespace App\Serializer;
+
+use App\Entity\Parts\Part;
+use App\Entity\Parts\PartLot;
+use App\Entity\Parts\StorageLocation;
+use App\Entity\Parts\Supplier;
+use App\Entity\PriceInformations\Orderdetail;
+use App\Entity\PriceInformations\Pricedetail;
+use Brick\Math\BigDecimal;
+use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
+use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
+use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
+
+/**
+ * @see \App\Tests\Serializer\PartNormalizerTest
+ */
+class PartNormalizer implements NormalizerInterface, DenormalizerInterface, NormalizerAwareInterface, DenormalizerAwareInterface
+{
+
+ use NormalizerAwareTrait;
+ use DenormalizerAwareTrait;
+
+ private const ALREADY_CALLED = 'PART_NORMALIZER_ALREADY_CALLED';
+
+ private const DENORMALIZE_KEY_MAPPING = [
+ 'notes' => 'comment',
+ 'quantity' => 'instock',
+ 'amount' => 'instock',
+ 'mpn' => 'manufacturer_product_number',
+ 'spn' => 'supplier_part_number',
+ 'supplier_product_number' => 'supplier_part_number',
+ 'storage_location' => 'storelocation',
+ ];
+
+ public function __construct(
+ private readonly StructuralElementFromNameDenormalizer $locationDenormalizer,
+ )
+ {
+ }
+
+ public function supportsNormalization($data, ?string $format = null, array $context = []): bool
+ {
+ //We only remove the type field for CSV export
+ return !isset($context[self::ALREADY_CALLED]) && $format === 'csv' && $data instanceof Part ;
+ }
+
+ public function normalize($object, ?string $format = null, array $context = []): array
+ {
+ if (!$object instanceof Part) {
+ throw new \InvalidArgumentException('This normalizer only supports Part objects!');
+ }
+
+ $context[self::ALREADY_CALLED] = true;
+
+ //Prevent exception in API Platform
+ if ($object->getID() === null) {
+ $context['iri'] = 'not-persisted';
+ }
+
+ $data = $this->normalizer->normalize($object, $format, $context);
+
+ //Remove type field for CSV export
+ if ($format === 'csv') {
+ unset($data['type']);
+ }
+
+ return $data;
+ }
+
+ public function supportsDenormalization($data, string $type, string $format = null, array $context = []): bool
+ {
+ //Only denormalize if we are doing a file import operation
+ if (!($context['partdb_import'] ?? false)) {
+ return false;
+ }
+
+ //Only make the denormalizer available on import operations
+ return !isset($context[self::ALREADY_CALLED])
+ && is_array($data) && is_a($type, Part::class, true);
+ }
+
+ private function normalizeKeys(array &$data): array
+ {
+ //Rename keys based on the mapping, while leaving the data untouched
+ foreach ($data as $key => $value) {
+ if (isset(self::DENORMALIZE_KEY_MAPPING[$key])) {
+ $data[self::DENORMALIZE_KEY_MAPPING[$key]] = $value;
+ unset($data[$key]);
+ }
+ }
+
+ return $data;
+ }
+
+ public function denormalize($data, string $type, ?string $format = null, array $context = []): ?Part
+ {
+ $this->normalizeKeys($data);
+
+ //Empty IPN should be null, or we get a constraint error
+ if (isset($data['ipn']) && $data['ipn'] === '') {
+ $data['ipn'] = null;
+ }
+
+ //Fill empty needs_review and needs_review_comment fields with false
+ if (empty($data['needs_review'])) {
+ $data['needs_review'] = false;
+ }
+ if (empty($data['favorite'])) {
+ $data['favorite'] = false;
+ }
+ if (empty($data['minamount'])) {
+ $data['minamount'] = 0.0;
+ }
+
+ $context[self::ALREADY_CALLED] = true;
+
+ $object = $this->denormalizer->denormalize($data, $type, $format, $context);
+
+ if (!$object instanceof Part) {
+ throw new \InvalidArgumentException('This normalizer only supports Part objects!');
+ }
+
+ if ((isset($data['instock']) && trim((string) $data['instock']) !== "") || (isset($data['storelocation']) && trim((string) $data['storelocation']) !== "")) {
+ $partLot = new PartLot();
+
+ if (isset($data['instock']) && $data['instock'] !== "") {
+ //Replace comma with dot
+ $instock = (float) str_replace(',', '.', (string) $data['instock']);
+
+ $partLot->setAmount($instock);
+ } else {
+ $partLot->setInstockUnknown(true);
+ }
+
+ if (isset($data['storelocation']) && $data['storelocation'] !== "") {
+ $location = $this->locationDenormalizer->denormalize($data['storelocation'], StorageLocation::class, $format, $context);
+ $partLot->setStorageLocation($location);
+ }
+
+ $object->addPartLot($partLot);
+ }
+
+ if (isset($data['supplier']) && $data['supplier'] !== "") {
+ $supplier = $this->locationDenormalizer->denormalize($data['supplier'], Supplier::class, $format, $context);
+
+ if ($supplier !== null) {
+ $orderdetail = new Orderdetail();
+ $orderdetail->setSupplier($supplier);
+
+ if (isset($data['supplier_part_number']) && $data['supplier_part_number'] !== "") {
+ $orderdetail->setSupplierpartnr($data['supplier_part_number']);
+ }
+
+ $object->addOrderdetail($orderdetail);
+
+ if (isset($data['price']) && $data['price'] !== "") {
+ $pricedetail = new Pricedetail();
+ $pricedetail->setMinDiscountQuantity(1);
+ $pricedetail->setPriceRelatedQuantity(1);
+ $price = BigDecimal::of(str_replace(',', '.', (string) $data['price']));
+ $pricedetail->setPrice($price);
+
+ $orderdetail->addPricedetail($pricedetail);
+ }
+ }
+ }
+
+ return $object;
+ }
+
+ /**
+ * @return bool[]
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ //Must be false, because we rely on is_array($data) in supportsDenormalization()
+ return [
+ Part::class => false,
+ ];
+ }
+}
diff --git a/src/Serializer/StructuralElementDenormalizer.php b/src/Serializer/StructuralElementDenormalizer.php
new file mode 100644
index 00000000..d9b03ae7
--- /dev/null
+++ b/src/Serializer/StructuralElementDenormalizer.php
@@ -0,0 +1,132 @@
+.
+ */
+namespace App\Serializer;
+
+use App\Entity\Base\AbstractStructuralDBElement;
+use App\Repository\StructuralDBElementRepository;
+use App\Serializer\APIPlatform\SkippableItemNormalizer;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
+use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
+use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
+
+/**
+ * @see \App\Tests\Serializer\StructuralElementDenormalizerTest
+ */
+class StructuralElementDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface
+{
+
+ use DenormalizerAwareTrait;
+
+ private const ALREADY_CALLED = 'STRUCTURAL_DENORMALIZER_ALREADY_CALLED';
+
+ private array $object_cache = [];
+
+ public function __construct(
+ private readonly EntityManagerInterface $entityManager)
+ {
+ }
+
+ public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []): bool
+ {
+ //Only denormalize if we are doing a file import operation
+ if (!($context['partdb_import'] ?? false)) {
+ return false;
+ }
+
+ //If we already handled this object, skip it
+ if (isset($context[self::ALREADY_CALLED])
+ && is_array($context[self::ALREADY_CALLED])
+ && in_array($data, $context[self::ALREADY_CALLED], true)) {
+ return false;
+ }
+
+ return is_array($data)
+ && is_subclass_of($type, AbstractStructuralDBElement::class)
+ //Only denormalize if we are doing a file import operation
+ && in_array('import', $context['groups'] ?? [], true);
+ }
+
+ /**
+ * @template T of AbstractStructuralDBElement
+ * @param $data
+ * @phpstan-param class-string $type
+ * @param string|null $format
+ * @param array $context
+ * @return AbstractStructuralDBElement|null
+ * @phpstan-return T|null
+ */
+ public function denormalize($data, string $type, ?string $format = null, array $context = []): ?AbstractStructuralDBElement
+ {
+ //Do not use API Platform's denormalizer
+ $context[SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER] = true;
+
+ if (!isset($context[self::ALREADY_CALLED])) {
+ $context[self::ALREADY_CALLED] = [];
+ }
+
+ $context[self::ALREADY_CALLED][] = $data;
+
+
+ /** @var AbstractStructuralDBElement $deserialized_entity */
+ $deserialized_entity = $this->denormalizer->denormalize($data, $type, $format, $context);
+
+ //Check if we already have the entity in the database (via path)
+ /** @var StructuralDBElementRepository $repo */
+ $repo = $this->entityManager->getRepository($type);
+
+ $path = $deserialized_entity->getFullPath(AbstractStructuralDBElement::PATH_DELIMITER_ARROW);
+ $db_elements = $repo->getEntityByPath($path, AbstractStructuralDBElement::PATH_DELIMITER_ARROW);
+ if ($db_elements !== []) {
+ //We already have the entity in the database, so we can return it
+ return end($db_elements);
+ }
+
+
+ //Check if we have created the entity in this request before (so we don't create multiple entities for the same path)
+ //Entities get saved in the cache by type and path
+ //We use a different cache for this then the objects created by a string value (saved in repo). However, that should not be a problem
+ //unless the user data has mixed structure between json data and a string path
+ if (isset($this->object_cache[$type][$path])) {
+ return $this->object_cache[$type][$path];
+ }
+
+ //Save the entity in the cache
+ $this->object_cache[$type][$path] = $deserialized_entity;
+
+ //We don't have the entity in the database, so we have to persist it
+ $this->entityManager->persist($deserialized_entity);
+
+ return $deserialized_entity;
+ }
+
+ public function getSupportedTypes(): array
+ {
+ //Must be false, because we use in_array in supportsDenormalization
+ return [
+ AbstractStructuralDBElement::class => false,
+ ];
+ }
+}
diff --git a/src/Serializer/StructuralElementFromNameDenormalizer.php b/src/Serializer/StructuralElementFromNameDenormalizer.php
new file mode 100644
index 00000000..1d7255b7
--- /dev/null
+++ b/src/Serializer/StructuralElementFromNameDenormalizer.php
@@ -0,0 +1,91 @@
+.
+ */
+namespace App\Serializer;
+
+use App\Entity\Base\AbstractStructuralDBElement;
+use App\Repository\StructuralDBElementRepository;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+
+/**
+ * @see \App\Tests\Serializer\StructuralElementFromNameDenormalizerTest
+ */
+class StructuralElementFromNameDenormalizer implements DenormalizerInterface
+{
+ public function __construct(private readonly EntityManagerInterface $em)
+ {
+ }
+
+ public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []): bool
+ {
+ //Only denormalize if we are doing a file import operation
+ if (!($context['partdb_import'] ?? false)) {
+ return false;
+ }
+
+ return is_string($data) && is_subclass_of($type, AbstractStructuralDBElement::class);
+ }
+
+ /**
+ * @template T of AbstractStructuralDBElement
+ * @phpstan-param class-string $type
+ * @phpstan-return T|null
+ */
+ public function denormalize($data, string $type, ?string $format = null, array $context = []): AbstractStructuralDBElement|null
+ {
+ //Retrieve the repository for the given type
+ /** @var StructuralDBElementRepository $repo */
+ $repo = $this->em->getRepository($type);
+
+ $path_delimiter = $context['path_delimiter'] ?? '->';
+
+ if ($context['create_unknown_datastructures'] ?? false) {
+ $elements = $repo->getNewEntityFromPath($data, $path_delimiter);
+ //Persist all new elements
+ foreach ($elements as $element) {
+ $this->em->persist($element);
+ }
+ if ($elements === []) {
+ return null;
+ }
+ return end($elements);
+ }
+
+ $elements = $repo->getEntityByPath($data, $path_delimiter);
+ if ($elements === []) {
+ return null;
+ }
+ return end($elements);
+ }
+
+ /**
+ * @return bool[]
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ //Cachable value Must be false, because we do an is_string check on data in supportsDenormalization
+ return [
+ AbstractStructuralDBElement::class => false
+ ];
+ }
+}
diff --git a/src/Serializer/StructuralElementNormalizer.php b/src/Serializer/StructuralElementNormalizer.php
new file mode 100644
index 00000000..e73f69be
--- /dev/null
+++ b/src/Serializer/StructuralElementNormalizer.php
@@ -0,0 +1,83 @@
+.
+ */
+namespace App\Serializer;
+
+use App\Entity\Base\AbstractStructuralDBElement;
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
+use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
+
+/**
+ * @see \App\Tests\Serializer\StructuralElementNormalizerTest
+ */
+class StructuralElementNormalizer implements NormalizerInterface
+{
+ public function __construct(
+ #[Autowire(service: ObjectNormalizer::class)]private readonly NormalizerInterface $normalizer
+ )
+ {
+ }
+
+ public function supportsNormalization($data, ?string $format = null, array $context = []): bool
+ {
+ //Only normalize if we are doing a file export operation
+ if (!($context['partdb_export'] ?? false)) {
+ return false;
+ }
+
+ return $data instanceof AbstractStructuralDBElement;
+ }
+
+ public function normalize($object, ?string $format = null, array $context = []): mixed
+ {
+ if (!$object instanceof AbstractStructuralDBElement) {
+ throw new \InvalidArgumentException('This normalizer only supports AbstractStructural objects!');
+ }
+
+ $data = $this->normalizer->normalize($object, $format, $context);
+
+ //If the data is not an array, we can't do anything with it
+ if (!is_array($data)) {
+ return $data;
+ }
+
+ //Remove type field for CSV export
+ if ($format === 'csv') {
+ unset($data['type']);
+ }
+
+ $data['full_name'] = $object->getFullPath('->');
+
+ return $data;
+ }
+
+ /**
+ * @return bool[]
+ */
+ public function getSupportedTypes(?string $format): array
+ {
+ return [
+ AbstractStructuralDBElement::class => true,
+ ];
+ }
+}
diff --git a/src/Services/Attachments/AttachmentManager.php b/src/Services/Attachments/AttachmentManager.php
index f789d3de..1075141b 100644
--- a/src/Services/Attachments/AttachmentManager.php
+++ b/src/Services/Attachments/AttachmentManager.php
@@ -35,11 +35,8 @@ use function strlen;
*/
class AttachmentManager
{
- protected AttachmentPathResolver $pathResolver;
-
- public function __construct(AttachmentPathResolver $pathResolver)
+ public function __construct(protected AttachmentPathResolver $pathResolver)
{
- $this->pathResolver = $pathResolver;
}
/**
@@ -47,35 +44,31 @@ class AttachmentManager
*
* @param Attachment $attachment The attachment for which the file should be generated
*
- * @return SplFileInfo|null The fileinfo for the attachment file. Null, if the attachment is external or has
+ * @return SplFileInfo|null The fileinfo for the attachment file. Null, if the attachment is only external or has
* invalid file.
*/
public function attachmentToFile(Attachment $attachment): ?SplFileInfo
{
- if ($attachment->isExternal() || !$this->isFileExisting($attachment)) {
+ if (!$this->isInternalFileExisting($attachment)) {
return null;
}
- return new SplFileInfo($this->toAbsoluteFilePath($attachment));
+ return new SplFileInfo($this->toAbsoluteInternalFilePath($attachment));
}
/**
- * Returns the absolute filepath of the attachment. Null is returned, if the attachment is externally saved,
- * or is not existing.
+ * Returns the absolute filepath to the internal copy of the attachment. Null is returned, if the attachment is
+ * only externally saved, or is not existing.
*
* @param Attachment $attachment The attachment for which the filepath should be determined
*/
- public function toAbsoluteFilePath(Attachment $attachment): ?string
+ public function toAbsoluteInternalFilePath(Attachment $attachment): ?string
{
- if (empty($attachment->getPath())) {
+ if (!$attachment->hasInternal()){
return null;
}
- if ($attachment->isExternal()) {
- return null;
- }
-
- $path = $this->pathResolver->placeholderToRealPath($attachment->getPath());
+ $path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
//realpath does not work with null as argument
if (null === $path) {
@@ -92,8 +85,8 @@ class AttachmentManager
}
/**
- * Checks if the file in this attachement is existing. This works for files on the HDD, and for URLs
- * (it's not checked if the ressource behind the URL is really existing, so for every external attachment true is returned).
+ * Checks if the file in this attachment is existing. This works for files on the HDD, and for URLs
+ * (it's not checked if the resource behind the URL is really existing, so for every external attachment true is returned).
*
* @param Attachment $attachment The attachment for which the existence should be checked
*
@@ -101,15 +94,23 @@ class AttachmentManager
*/
public function isFileExisting(Attachment $attachment): bool
{
- if (empty($attachment->getPath())) {
- return false;
- }
-
- if ($attachment->isExternal()) {
+ if($attachment->hasExternal()){
return true;
}
+ return $this->isInternalFileExisting($attachment);
+ }
- $absolute_path = $this->toAbsoluteFilePath($attachment);
+ /**
+ * Checks if the internal file in this attachment is existing. Returns false if the attachment doesn't have an
+ * internal file.
+ *
+ * @param Attachment $attachment The attachment for which the existence should be checked
+ *
+ * @return bool true if the file is existing
+ */
+ public function isInternalFileExisting(Attachment $attachment): bool
+ {
+ $absolute_path = $this->toAbsoluteInternalFilePath($attachment);
if (null === $absolute_path) {
return false;
@@ -120,27 +121,23 @@ class AttachmentManager
/**
* Returns the filesize of the attachments in bytes.
- * For external attachments or not existing attachments, null is returned.
+ * For purely external attachments or inexistent attachments, null is returned.
*
* @param Attachment $attachment the filesize for which the filesize should be calculated
*/
public function getFileSize(Attachment $attachment): ?int
{
- if ($attachment->isExternal()) {
+ if (!$this->isInternalFileExisting($attachment)) {
return null;
}
- if (!$this->isFileExisting($attachment)) {
- return null;
- }
-
- $tmp = filesize($this->toAbsoluteFilePath($attachment));
+ $tmp = filesize($this->toAbsoluteInternalFilePath($attachment));
return false !== $tmp ? $tmp : null;
}
/**
- * Returns a human readable version of the attachment file size.
+ * Returns a human-readable version of the attachment file size.
* For external attachments, null is returned.
*
* @param int $decimals The number of decimals numbers that should be printed
@@ -160,7 +157,7 @@ class AttachmentManager
$sz = 'BKMGTP';
$factor = (int) floor((strlen((string) $bytes) - 1) / 3);
-
- return sprintf("%.{$decimals}f", $bytes / 1024 ** $factor).@$sz[$factor];
+ //Use real (10 based) SI prefixes
+ return sprintf("%.{$decimals}f", $bytes / 1000 ** $factor).@$sz[$factor];
}
}
diff --git a/src/Services/Attachments/AttachmentPathResolver.php b/src/Services/Attachments/AttachmentPathResolver.php
index 9617024e..1b52c89b 100644
--- a/src/Services/Attachments/AttachmentPathResolver.php
+++ b/src/Services/Attachments/AttachmentPathResolver.php
@@ -22,24 +22,22 @@ declare(strict_types=1);
namespace App\Services\Attachments;
-use FontLib\Table\Type\maxp;
use const DIRECTORY_SEPARATOR;
use Symfony\Component\Filesystem\Filesystem;
/**
* This service converts the relative pathes for attachments saved in database (like %MEDIA%/img.jpg) to real pathes
* an vice versa.
+ * @see \App\Tests\Services\Attachments\AttachmentPathResolverTest
*/
class AttachmentPathResolver
{
- protected string $project_dir;
-
- protected ?string $media_path;
- protected ?string $footprints_path;
+ protected string $media_path;
+ protected string $footprints_path;
protected ?string $models_path;
protected ?string $secure_path;
- protected array $placeholders;
+ protected array $placeholders = ['%MEDIA%', '%BASE%/data/media', '%FOOTPRINTS%', '%FOOTPRINTS_3D%', '%SECURE%'];
protected array $pathes;
protected array $placeholders_regex;
protected array $pathes_regex;
@@ -53,18 +51,13 @@ class AttachmentPathResolver
* Set to null if this ressource should be disabled.
* @param string|null $models_path set to null if this ressource should be disabled
*/
- public function __construct(string $project_dir, string $media_path, string $secure_path, ?string $footprints_path, ?string $models_path)
+ public function __construct(protected string $project_dir, string $media_path, string $secure_path, ?string $footprints_path, ?string $models_path)
{
- $this->project_dir = $project_dir;
-
- //Determine the path for our ressources
- $this->media_path = $this->parameterToAbsolutePath($media_path);
- $this->footprints_path = $this->parameterToAbsolutePath($footprints_path);
+ //Determine the path for our resources
+ $this->media_path = $this->parameterToAbsolutePath($media_path) ?? throw new \InvalidArgumentException('The media path must be set and valid!');
+ $this->secure_path = $this->parameterToAbsolutePath($secure_path) ?? throw new \InvalidArgumentException('The secure path must be set and valid!');
+ $this->footprints_path = $this->parameterToAbsolutePath($footprints_path) ;
$this->models_path = $this->parameterToAbsolutePath($models_path);
- $this->secure_path = $this->parameterToAbsolutePath($secure_path);
-
- //Here we define the valid placeholders and their replacement values
- $this->placeholders = ['%MEDIA%', '%BASE%/data/media', '%FOOTPRINTS%', '%FOOTPRINTS_3D%', '%SECURE%'];
$this->pathes = [$this->media_path, $this->media_path, $this->footprints_path, $this->models_path, $this->secure_path];
//Remove all disabled placeholders
@@ -122,19 +115,23 @@ class AttachmentPathResolver
* Converts an relative placeholder filepath (with %MEDIA% or older %BASE%) to an absolute filepath on disk.
* The directory separator is always /. Relative pathes are not realy possible (.. is striped).
*
- * @param string $placeholder_path the filepath with placeholder for which the real path should be determined
+ * @param string|null $placeholder_path the filepath with placeholder for which the real path should be determined
*
* @return string|null The absolute real path of the file, or null if the placeholder path is invalid
*/
- public function placeholderToRealPath(string $placeholder_path): ?string
+ public function placeholderToRealPath(?string $placeholder_path): ?string
{
+ if (null === $placeholder_path) {
+ return null;
+ }
+
//The new attachments use %MEDIA% as placeholders, which is the directory set in media_directory
//Older path entries are given via %BASE% which was the project root
$count = 0;
//When path is a footprint we have to first run the string through our lecagy german mapping functions
- if (strpos($placeholder_path, '%FOOTPRINTS%') !== false) {
+ if (str_contains($placeholder_path, '%FOOTPRINTS%')) {
$placeholder_path = $this->convertOldFootprintPath($placeholder_path);
}
@@ -146,12 +143,12 @@ class AttachmentPathResolver
}
//If we have now have a placeholder left, the string is invalid:
- if (preg_match('#%\w+%#', $placeholder_path)) {
+ if (preg_match('#%\w+%#', (string) $placeholder_path)) {
return null;
}
//Path is invalid if path is directory traversal
- if (false !== strpos($placeholder_path, '..')) {
+ if (str_contains((string) $placeholder_path, '..')) {
return null;
}
@@ -190,7 +187,7 @@ class AttachmentPathResolver
}
//If the new string does not begin with a placeholder, it is invalid
- if (!preg_match('#^%\w+%#', $real_path)) {
+ if (!preg_match('#^%\w+%#', (string) $real_path)) {
return null;
}
@@ -198,7 +195,7 @@ class AttachmentPathResolver
}
/**
- * The path where uploaded attachments is stored.
+ * The path where uploaded attachments is stored.
*
* @return string the absolute path to the media folder
*/
@@ -208,8 +205,8 @@ class AttachmentPathResolver
}
/**
- * The path where secured attachments are stored. Must not be located in public/ folder, so it can only be accessed
- * via the attachment controller.
+ * The path where secured attachments are stored. Must not be located in public/ folder, so it can only be accessed
+ * via the attachment controller.
*
* @return string the absolute path to the secure path
*/
@@ -221,7 +218,7 @@ class AttachmentPathResolver
/**
* The string where the builtin footprints are stored.
*
- * @return string|null The absolute path to the footprints folder. Null if built footprints were disabled.
+ * @return string|null The absolute path to the footprints' folder. Null if built footprints were disabled.
*/
public function getFootprintsPath(): ?string
{
@@ -231,7 +228,7 @@ class AttachmentPathResolver
/**
* The string where the builtin 3D models are stored.
*
- * @return string|null The absolute path to the models folder. Null if builtin models were disabled.
+ * @return string|null The absolute path to the models' folder. Null if builtin models were disabled.
*/
public function getModelsPath(): ?string
{
@@ -248,7 +245,7 @@ class AttachmentPathResolver
$ret = [];
foreach ($array as $item) {
- $item = str_replace(['\\'], ['/'], $item);
+ $item = str_replace(['\\'], ['/'], (string) $item);
$ret[] = '/'.preg_quote($item, '/').'/';
}
diff --git a/src/Services/Attachments/AttachmentReverseSearch.php b/src/Services/Attachments/AttachmentReverseSearch.php
index 34a6b929..e05192d0 100644
--- a/src/Services/Attachments/AttachmentReverseSearch.php
+++ b/src/Services/Attachments/AttachmentReverseSearch.php
@@ -30,22 +30,12 @@ use SplFileInfo;
use Symfony\Component\Filesystem\Filesystem;
/**
- * This service provides functions to find attachments via an reverse search based on a file.
+ * This service provides functions to find attachments via a reverse search based on a file.
*/
class AttachmentReverseSearch
{
- protected EntityManagerInterface $em;
- protected AttachmentPathResolver $pathResolver;
- protected CacheManager $cacheManager;
- protected AttachmentURLGenerator $attachmentURLGenerator;
-
- public function __construct(EntityManagerInterface $em, AttachmentPathResolver $pathResolver,
- CacheManager $cacheManager, AttachmentURLGenerator $attachmentURLGenerator)
+ public function __construct(protected EntityManagerInterface $em, protected AttachmentPathResolver $pathResolver, protected CacheManager $cacheManager, protected AttachmentURLGenerator $attachmentURLGenerator)
{
- $this->em = $em;
- $this->pathResolver = $pathResolver;
- $this->cacheManager = $cacheManager;
- $this->attachmentURLGenerator = $attachmentURLGenerator;
}
/**
@@ -53,7 +43,7 @@ class AttachmentReverseSearch
*
* @param SplFileInfo $file The file for which is searched
*
- * @return Attachment[] an list of attachments that use the given file
+ * @return Attachment[] a list of attachments that use the given file
*/
public function findAttachmentsByFile(SplFileInfo $file): array
{
@@ -65,7 +55,7 @@ class AttachmentReverseSearch
$repo = $this->em->getRepository(Attachment::class);
return $repo->findBy([
- 'path' => [$relative_path_new, $relative_path_old],
+ 'internal_path' => [$relative_path_new, $relative_path_old],
]);
}
@@ -75,11 +65,11 @@ class AttachmentReverseSearch
* @param SplFileInfo $file The file that should be removed
* @param int $threshold the threshold used, to determine if a file should be deleted or not
*
- * @return bool True, if the file was delete. False if not.
+ * @return bool True, if the file was deleted. False if not.
*/
public function deleteIfNotUsed(SplFileInfo $file, int $threshold = 1): bool
{
- /* When the file is used more then $threshold times, don't delete it */
+ /* When the file is used more than $threshold times, don't delete it */
if (count($this->findAttachmentsByFile($file)) > $threshold) {
return false;
}
diff --git a/src/Services/Attachments/AttachmentSubmitHandler.php b/src/Services/Attachments/AttachmentSubmitHandler.php
index cd3afcac..89457cea 100644
--- a/src/Services/Attachments/AttachmentSubmitHandler.php
+++ b/src/Services/Attachments/AttachmentSubmitHandler.php
@@ -26,6 +26,7 @@ use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\AttachmentTypeAttachment;
+use App\Entity\Attachments\AttachmentUpload;
use App\Entity\Attachments\CategoryAttachment;
use App\Entity\Attachments\CurrencyAttachment;
use App\Entity\Attachments\LabelAttachment;
@@ -35,18 +36,18 @@ use App\Entity\Attachments\GroupAttachment;
use App\Entity\Attachments\ManufacturerAttachment;
use App\Entity\Attachments\MeasurementUnitAttachment;
use App\Entity\Attachments\PartAttachment;
-use App\Entity\Attachments\StorelocationAttachment;
+use App\Entity\Attachments\StorageLocationAttachment;
use App\Entity\Attachments\SupplierAttachment;
use App\Entity\Attachments\UserAttachment;
use App\Exceptions\AttachmentDownloadException;
+use Hshn\Base64EncodedFile\HttpFoundation\File\Base64EncodedFile;
+use Hshn\Base64EncodedFile\HttpFoundation\File\UploadedBase64EncodedFile;
use const DIRECTORY_SEPARATOR;
-use function get_class;
use InvalidArgumentException;
use RuntimeException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Mime\MimeTypesInterface;
-use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
@@ -55,28 +56,21 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
*/
class AttachmentSubmitHandler
{
- protected AttachmentPathResolver $pathResolver;
protected array $folder_mapping;
- protected bool $allow_attachments_downloads;
- protected HttpClientInterface $httpClient;
- protected MimeTypesInterface $mimeTypes;
- protected FileTypeFilterTools $filterTools;
+
+ private ?int $max_upload_size_bytes = null;
protected const BLACKLISTED_EXTENSIONS = ['php', 'phtml', 'php3', 'ph3', 'php4', 'ph4', 'php5', 'ph5', 'phtm', 'sh',
'asp', 'cgi', 'py', 'pl', 'exe', 'aspx', 'js', 'mjs', 'jsp', 'css', 'jar', 'html', 'htm', 'shtm', 'shtml', 'htaccess',
'htpasswd', ''];
- public function __construct(AttachmentPathResolver $pathResolver, bool $allow_attachments_downloads,
- HttpClientInterface $httpClient, MimeTypesInterface $mimeTypes,
- FileTypeFilterTools $filterTools)
+ public function __construct(protected AttachmentPathResolver $pathResolver, protected bool $allow_attachments_downloads,
+ protected HttpClientInterface $httpClient, protected MimeTypesInterface $mimeTypes, protected readonly SVGSanitizer $SVGSanitizer,
+ protected FileTypeFilterTools $filterTools, /**
+ * @var string The user configured maximum upload size. This is a string like "10M" or "1G" and will be converted to
+ */
+ protected string $max_upload_size)
{
- $this->pathResolver = $pathResolver;
- $this->allow_attachments_downloads = $allow_attachments_downloads;
- $this->httpClient = $httpClient;
- $this->mimeTypes = $mimeTypes;
-
- $this->filterTools = $filterTools;
-
//The mapping used to determine which folder will be used for an attachment type
$this->folder_mapping = [
PartAttachment::class => 'part',
@@ -88,7 +82,7 @@ class AttachmentSubmitHandler
GroupAttachment::class => 'group',
ManufacturerAttachment::class => 'manufacturer',
MeasurementUnitAttachment::class => 'measurement_unit',
- StorelocationAttachment::class => 'storelocation',
+ StorageLocationAttachment::class => 'storelocation',
SupplierAttachment::class => 'supplier',
UserAttachment::class => 'user',
LabelAttachment::class => 'label_profile',
@@ -101,8 +95,8 @@ class AttachmentSubmitHandler
*/
public function isValidFileExtension(AttachmentType $attachment_type, UploadedFile $uploadedFile): bool
{
- //Only validate if the attachment type has specified an filetype filter:
- if (empty($attachment_type->getFiletypeFilter())) {
+ //Only validate if the attachment type has specified a filetype filter:
+ if ($attachment_type->getFiletypeFilter() === '') {
return true;
}
@@ -114,10 +108,10 @@ class AttachmentSubmitHandler
/**
* Generates a filename for the given attachment and extension.
- * The filename contains a random id, so every time this function is called you get an unique name.
+ * The filename contains a random id, so every time this function is called you get a unique name.
*
* @param Attachment $attachment The attachment that should be used for generating an attachment
- * @param string $extension The extension that the new file should have (must only contain chars allowed in pathes)
+ * @param string $extension The extension that the new file should have (must only contain chars allowed in paths)
*
* @return string the new filename
*/
@@ -129,7 +123,7 @@ class AttachmentSubmitHandler
$extension
);
- //Use the (sanatized) attachment name as an filename part
+ //Use the (sanatized) attachment name as a filename part
$safeName = transliterator_transliterate(
'Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()',
$attachment->getName()
@@ -148,84 +142,118 @@ class AttachmentSubmitHandler
*/
public function generateAttachmentPath(Attachment $attachment, bool $secure_upload = false): string
{
- if ($secure_upload) {
- $base_path = $this->pathResolver->getSecurePath();
+ $base_path = $secure_upload ? $this->pathResolver->getSecurePath() : $this->pathResolver->getMediaPath();
+
+ //Ensure the attachment has an assigned element
+ if (!$attachment->getElement() instanceof AttachmentContainingDBElement) {
+ throw new InvalidArgumentException('The given attachment is not assigned to an element! An element is needed to generate a path!');
+ }
+
+ //Determine the folder prefix for the given attachment class:
+ $prefix = null;
+ //Check if we can use the class name dire
+ if (isset($this->folder_mapping[$attachment::class])) {
+ $prefix = $this->folder_mapping[$attachment::class];
} else {
- $base_path = $this->pathResolver->getMediaPath();
+ //If not, check for instance of:
+ foreach ($this->folder_mapping as $class => $folder) {
+ if ($attachment instanceof $class) {
+ $prefix = $folder;
+ break;
+ }
+ }
}
//Ensure the given attachment class is known to mapping
- if (!isset($this->folder_mapping[get_class($attachment)])) {
- throw new InvalidArgumentException('The given attachment class is not known! The passed class was: '.get_class($attachment));
- }
- //Ensure the attachment has an assigned element
- if (null === $attachment->getElement()) {
- throw new InvalidArgumentException('The given attachment is not assigned to an element! An element is needed to generate a path!');
+ if (!$prefix) {
+ throw new InvalidArgumentException('The given attachment class is not known! The passed class was: '.$attachment::class);
}
//Build path
return
$base_path.DIRECTORY_SEPARATOR //Base path
- .$this->folder_mapping[get_class($attachment)].DIRECTORY_SEPARATOR.$attachment->getElement()->getID();
+ .$prefix.DIRECTORY_SEPARATOR.$attachment->getElement()->getID();
}
/**
- * Handle the submit of an attachment form.
+ * Handle submission of an attachment form.
* This function will move the uploaded file or download the URL file to server, if needed.
*
* @param Attachment $attachment the attachment that should be used for handling
- * @param UploadedFile|null $file If given, that file will be moved to the right location
- * @param array $options The options to use with the upload. Here you can specify that an URL should be downloaded,
- * or an file should be moved to a secure location.
+ * @param AttachmentUpload|null $upload The upload options DTO. If it is null, it will be tried to get from the attachment option
*
* @return Attachment The attachment with the new filename (same instance as passed $attachment)
*/
- public function handleFormSubmit(Attachment $attachment, ?UploadedFile $file, array $options = []): Attachment
+ public function handleUpload(Attachment $attachment, ?AttachmentUpload $upload): Attachment
{
- $resolver = new OptionsResolver();
- $this->configureOptions($resolver);
- $options = $resolver->resolve($options);
+ if ($upload === null) {
+ $upload = $attachment->getUpload();
+ if ($upload === null) {
+ throw new InvalidArgumentException('No upload options given and no upload options set in attachment!');
+ }
+ }
+
+ $file = $upload->file;
+
+ //If no file was uploaded, but we have base64 encoded data, create a file from it
+ if (!$file && $upload->data !== null) {
+ $file = new UploadedBase64EncodedFile(new Base64EncodedFile($upload->data), $upload->filename ?? 'base64');
+ }
+
+ //By default we assume a public upload
+ $secure_attachment = $upload->private ?? false;
//When a file is given then upload it, otherwise check if we need to download the URL
- if ($file) {
- $this->upload($attachment, $file, $options);
- } elseif ($options['download_url'] && $attachment->isExternal()) {
- $this->downloadURL($attachment, $options);
+ if ($file instanceof UploadedFile) {
+
+ $this->upload($attachment, $file, $secure_attachment);
+ } elseif ($upload->downloadUrl && $attachment->hasExternal()) {
+ $this->downloadURL($attachment, $secure_attachment);
}
//Move the attachment files to secure location (and back) if needed
- $this->moveFile($attachment, $options['secure_attachment']);
+ $this->moveFile($attachment, $secure_attachment);
+
+ //Sanitize the SVG if needed
+ $this->sanitizeSVGAttachment($attachment);
//Rename blacklisted (unsecure) files to a better extension
$this->renameBlacklistedExtensions($attachment);
- //Check if we should assign this attachment to master picture
- //this is only possible if the attachment is new (not yet persisted to DB)
- if ($options['become_preview_if_empty'] && null === $attachment->getID() && $attachment->isPicture()) {
- $element = $attachment->getElement();
- if ($element instanceof AttachmentContainingDBElement && null === $element->getMasterPictureAttachment()) {
+ //Set / Unset the master picture attachment / preview image
+ $element = $attachment->getElement();
+ if ($element instanceof AttachmentContainingDBElement) {
+ //Make this attachment the master picture if needed and this was requested
+ if ($upload->becomePreviewIfEmpty
+ && $element->getMasterPictureAttachment() === null //Element must not have an preview image yet
+ && null === $attachment->getID() //Attachment must be null
+ && $attachment->isPicture() //Attachment must be a picture
+ ) {
$element->setMasterPictureAttachment($attachment);
}
+
+ //If this attachment is the master picture, but is not a picture anymore, dont use it as master picture anymore
+ if ($element->getMasterPictureAttachment() === $attachment && !$attachment->isPicture()) {
+ $element->setMasterPictureAttachment(null);
+ }
}
return $attachment;
}
/**
- * Rename attachments with an unsafe extension (meaning files which would be runned by a to a safe one.
- * @param Attachment $attachment
- * @return Attachment
+ * Rename attachments with an unsafe extension (meaning files which would be run by a to a safe one).
*/
protected function renameBlacklistedExtensions(Attachment $attachment): Attachment
{
//We can not do anything on builtins or external ressources
- if ($attachment->isBuiltIn() || $attachment->isExternal()) {
+ if ($attachment->isBuiltIn() || !$attachment->hasInternal()) {
return $attachment;
}
//Determine the old filepath
- $old_path = $this->pathResolver->placeholderToRealPath($attachment->getPath());
- if (empty($old_path) || !file_exists($old_path)) {
+ $old_path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
+ if ($old_path === null || $old_path === '' || !file_exists($old_path)) {
return $attachment;
}
$filename = basename($old_path);
@@ -233,45 +261,34 @@ class AttachmentSubmitHandler
//Check if the extension is blacklisted and replace the file extension with txt if needed
- if(in_array($ext, self::BLACKLISTED_EXTENSIONS)) {
+ if(in_array($ext, self::BLACKLISTED_EXTENSIONS, true)) {
$new_path = $this->generateAttachmentPath($attachment, $attachment->isSecure())
- .DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, 'txt');
+ .DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, 'txt');
//Move file to new directory
$fs = new Filesystem();
$fs->rename($old_path, $new_path);
//Update the attachment
- $attachment->setPath($this->pathResolver->realPathToPlaceholder($new_path));
+ $attachment->setInternalPath($this->pathResolver->realPathToPlaceholder($new_path));
}
return $attachment;
}
- protected function configureOptions(OptionsResolver $resolver): void
- {
- $resolver->setDefaults([
- //If no preview image was set yet, the new uploaded file will become the preview image
- 'become_preview_if_empty' => true,
- //When an URL is given download the URL
- 'download_url' => false,
- 'secure_attachment' => false,
- ]);
- }
-
/**
- * Move the given attachment to secure location (or back to public folder) if needed.
+ * Move the internal copy of the given attachment to a secure location (or back to public folder) if needed.
*
* @param Attachment $attachment the attachment for which the file should be moved
* @param bool $secure_location this value determines, if the attachment is moved to the secure or public folder
*
- * @return Attachment The attachment with the updated filepath
+ * @return Attachment The attachment with the updated internal filepath
*/
protected function moveFile(Attachment $attachment, bool $secure_location): Attachment
{
//We can not do anything on builtins or external ressources
- if ($attachment->isBuiltIn() || $attachment->isExternal()) {
+ if ($attachment->isBuiltIn() || !$attachment->hasInternal()) {
return $attachment;
}
@@ -281,12 +298,12 @@ class AttachmentSubmitHandler
}
//Determine the old filepath
- $old_path = $this->pathResolver->placeholderToRealPath($attachment->getPath());
+ $old_path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
if (!file_exists($old_path)) {
return $attachment;
}
- $filename = basename($old_path);
+ $filename = basename((string) $old_path);
//If the basename is not one of the new unique on, we have to save the old filename
if (!preg_match('#\w+-\w{13}\.#', $filename)) {
//Save filename to attachment field
@@ -305,7 +322,7 @@ class AttachmentSubmitHandler
//Save info to attachment entity
$new_path = $this->pathResolver->realPathToPlaceholder($new_path);
- $attachment->setPath($new_path);
+ $attachment->setInternalPath($new_path);
return $attachment;
}
@@ -313,27 +330,46 @@ class AttachmentSubmitHandler
/**
* Download the URL set in the attachment and save it on the server.
*
- * @param array $options The options from the handleFormSubmit function
+ * @param bool $secureAttachment True if the file should be moved to the secure attachment storage
*
- * @return Attachment The attachment with the new filepath
+ * @return Attachment The attachment with the downloaded copy
*/
- protected function downloadURL(Attachment $attachment, array $options): Attachment
+ protected function downloadURL(Attachment $attachment, bool $secureAttachment): Attachment
{
//Check if we are allowed to download files
if (!$this->allow_attachments_downloads) {
throw new RuntimeException('Download of attachments is not allowed!');
}
- $url = $attachment->getURL();
+ $url = $attachment->getExternalPath();
$fs = new Filesystem();
- $attachment_folder = $this->generateAttachmentPath($attachment, $options['secure_attachment']);
+ $attachment_folder = $this->generateAttachmentPath($attachment, $secureAttachment);
$tmp_path = $attachment_folder.DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, 'tmp');
try {
- $response = $this->httpClient->request('GET', $url, [
+ $opts = [
'buffer' => false,
- ]);
+ //Use user-agent and other headers to make the server think we are a browser
+ 'headers' => [
+ "sec-ch-ua" => "\"Not(A:Brand\";v=\"99\", \"Google Chrome\";v=\"133\", \"Chromium\";v=\"133\"",
+ "sec-ch-ua-mobile" => "?0",
+ "sec-ch-ua-platform" => "\"Windows\"",
+ "user-agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
+ "sec-fetch-site" => "none",
+ "sec-fetch-mode" => "navigate",
+ ],
+
+ ];
+ $response = $this->httpClient->request('GET', $url, $opts);
+ //Digikey wants TLSv1.3, so try again with that if we get a 403
+ if ($response->getStatusCode() === 403) {
+ $opts['crypto_method'] = STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;
+ $response = $this->httpClient->request('GET', $url, $opts);
+ }
+ # if you have these changes and downloads still fail, check if it's due to an unknown certificate. Curl by
+ # default uses the systems ca store and that doesn't contain all the intermediate certificates needed to
+ # verify the leafs
if (200 !== $response->getStatusCode()) {
throw new AttachmentDownloadException('Status code: '.$response->getStatusCode());
@@ -350,27 +386,29 @@ class AttachmentSubmitHandler
//File download should be finished here, so determine the new filename and extension
$headers = $response->getHeaders();
- //Try to determine an filename
+ //Try to determine a filename
$filename = '';
- //If an content disposition header was set try to extract the filename out of it
+ //If a content disposition header was set try to extract the filename out of it
if (isset($headers['content-disposition'])) {
$tmp = [];
- preg_match('/[^;\\n=]*=([\'\"])*(.*)(?(1)\1|)/', $headers['content-disposition'][0], $tmp);
- $filename = $tmp[2];
+ //Only use the filename if the regex matches properly
+ if (preg_match('/[^;\\n=]*=([\'\"])*(.*)(?(1)\1|)/', $headers['content-disposition'][0], $tmp)) {
+ $filename = $tmp[2];
+ }
}
- //If we dont know filename yet, try to determine it out of url
+ //If we don't know filename yet, try to determine it out of url
if ('' === $filename) {
- $filename = basename(parse_url($url, PHP_URL_PATH));
+ $filename = basename(parse_url((string) $url, PHP_URL_PATH));
}
//Set original file
$attachment->setFilename($filename);
- //Check if we have a extension given
+ //Check if we have an extension given
$pathinfo = pathinfo($filename);
- if (!empty($pathinfo['extension'])) {
+ if (isset($pathinfo['extension']) && $pathinfo['extension'] !== '') {
$new_ext = $pathinfo['extension'];
} else { //Otherwise we have to guess the extension for the new file, based on its content
$new_ext = $this->mimeTypes->getExtensions($this->mimeTypes->guessMimeType($tmp_path))[0] ?? 'tmp';
@@ -383,8 +421,8 @@ class AttachmentSubmitHandler
//Make our file path relative to %BASE%
$new_path = $this->pathResolver->realPathToPlaceholder($new_path);
//Save the path to the attachment
- $attachment->setPath($new_path);
- } catch (TransportExceptionInterface $transportExceptionInterface) {
+ $attachment->setInternalPath($new_path);
+ } catch (TransportExceptionInterface) {
throw new AttachmentDownloadException('Transport error!');
}
@@ -396,25 +434,99 @@ class AttachmentSubmitHandler
*
* @param Attachment $attachment The attachment in which the file should be saved
* @param UploadedFile $file The file which was uploaded
- * @param array $options The options from the handleFormSubmit function
+ * @param bool $secureAttachment True if the file should be moved to the secure attachment storage
*
* @return Attachment The attachment with the new filepath
*/
- protected function upload(Attachment $attachment, UploadedFile $file, array $options): Attachment
+ protected function upload(Attachment $attachment, UploadedFile $file, bool $secureAttachment): Attachment
{
- //Move our temporay attachment to its final location
+ //Move our temporary attachment to its final location
$file_path = $file->move(
- $this->generateAttachmentPath($attachment, $options['secure_attachment']),
+ $this->generateAttachmentPath($attachment, $secureAttachment),
$this->generateAttachmentFilename($attachment, $file->getClientOriginalExtension())
)->getRealPath();
//Make our file path relative to %BASE%
$file_path = $this->pathResolver->realPathToPlaceholder($file_path);
//Save the path to the attachment
- $attachment->setPath($file_path);
+ $attachment->setInternalPath($file_path);
+ //reset any external paths the attachment might have had
+ $attachment->setExternalPath(null);
//And save original filename
$attachment->setFilename($file->getClientOriginalName());
return $attachment;
}
+
+ /**
+ * Parses the given file size string and returns the size in bytes.
+ * Taken from https://github.com/symfony/symfony/blob/6.2/src/Symfony/Component/Validator/Constraints/File.php
+ */
+ private function parseFileSizeString(string $maxSize): int
+ {
+ $factors = [
+ 'k' => 1000,
+ 'ki' => 1 << 10,
+ 'm' => 1000 * 1000,
+ 'mi' => 1 << 20,
+ 'g' => 1000 * 1000 * 1000,
+ 'gi' => 1 << 30,
+ ];
+ if (ctype_digit($maxSize)) {
+ return (int) $maxSize;
+ }
+
+ if (preg_match('/^(\d++)('.implode('|', array_keys($factors)).')$/i', $maxSize, $matches)) {
+ return (((int) $matches[1]) * $factors[strtolower($matches[2])]);
+ }
+
+ throw new RuntimeException(sprintf('"%s" is not a valid maximum size.', $maxSize));
+ }
+
+ /*
+ * Returns the maximum allowed upload size in bytes.
+ * This is the minimum value of Part-DB max_file_size, and php.ini's post_max_size and upload_max_filesize.
+ */
+ public function getMaximumAllowedUploadSize(): int
+ {
+ if ($this->max_upload_size_bytes) {
+ return $this->max_upload_size_bytes;
+ }
+
+ $this->max_upload_size_bytes = min(
+ $this->parseFileSizeString(ini_get('post_max_size')),
+ $this->parseFileSizeString(ini_get('upload_max_filesize')),
+ $this->parseFileSizeString($this->max_upload_size),
+ );
+
+ return $this->max_upload_size_bytes;
+ }
+
+ /**
+ * Sanitizes the given SVG file, if the attachment is an internal SVG file.
+ * @param Attachment $attachment
+ * @return Attachment
+ */
+ public function sanitizeSVGAttachment(Attachment $attachment): Attachment
+ {
+ //We can not do anything on builtins or external ressources
+ if ($attachment->isBuiltIn() || !$attachment->hasInternal()) {
+ return $attachment;
+ }
+
+ //Resolve the path to the file
+ $path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
+
+ //Check if the file exists
+ if (!file_exists($path)) {
+ return $attachment;
+ }
+
+ //Check if the file is an SVG
+ if ($attachment->getExtension() === "svg") {
+ $this->SVGSanitizer->sanitizeFile($path);
+ }
+
+ return $attachment;
+ }
}
diff --git a/src/Services/Attachments/AttachmentURLGenerator.php b/src/Services/Attachments/AttachmentURLGenerator.php
index c66d76fd..c22cefe4 100644
--- a/src/Services/Attachments/AttachmentURLGenerator.php
+++ b/src/Services/Attachments/AttachmentURLGenerator.php
@@ -22,43 +22,32 @@ declare(strict_types=1);
namespace App\Services\Attachments;
+use Imagine\Exception\RuntimeException;
use App\Entity\Attachments\Attachment;
use InvalidArgumentException;
-use Liip\ImagineBundle\Service\FilterService;
+use Liip\ImagineBundle\Imagine\Cache\CacheManager;
use Psr\Log\LoggerInterface;
-use RuntimeException;
use function strlen;
use Symfony\Component\Asset\Packages;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+/**
+ * @see \App\Tests\Services\Attachments\AttachmentURLGeneratorTest
+ */
class AttachmentURLGenerator
{
- protected Packages $assets;
protected string $public_path;
- protected AttachmentPathResolver $pathResolver;
- protected UrlGeneratorInterface $urlGenerator;
- protected AttachmentManager $attachmentHelper;
- protected FilterService $filterService;
- protected LoggerInterface $logger;
-
- public function __construct(Packages $assets, AttachmentPathResolver $pathResolver,
- UrlGeneratorInterface $urlGenerator, AttachmentManager $attachmentHelper,
- FilterService $filterService, LoggerInterface $logger)
+ public function __construct(protected Packages $assets, protected AttachmentPathResolver $pathResolver,
+ protected UrlGeneratorInterface $urlGenerator, protected AttachmentManager $attachmentHelper,
+ protected CacheManager $thumbnailManager, protected LoggerInterface $logger)
{
- $this->assets = $assets;
- $this->pathResolver = $pathResolver;
- $this->urlGenerator = $urlGenerator;
- $this->attachmentHelper = $attachmentHelper;
- $this->filterService = $filterService;
- $this->logger = $logger;
-
//Determine a normalized path to the public folder (assets are relative to this folder)
$this->public_path = $this->pathResolver->parameterToAbsolutePath('public');
}
/**
- * Converts the absolute file path to a version relative to the public folder, that can be passed to asset
+ * Converts the absolute file path to a version relative to the public folder, that can be passed to the
* Asset Component functions.
*
* @param string $absolute_path the absolute path that should be converted
@@ -78,8 +67,8 @@ class AttachmentURLGenerator
$public_path = $this->public_path;
}
- //Our absolute path must begin with public path or we can not use it for asset pathes.
- if (0 !== strpos($absolute_path, $public_path)) {
+ //Our absolute path must begin with public path, or we can not use it for asset pathes.
+ if (!str_starts_with($absolute_path, $public_path)) {
return null;
}
@@ -88,7 +77,7 @@ class AttachmentURLGenerator
}
/**
- * Converts a placeholder path to a path to a image path.
+ * Converts a placeholder path to a path to an image path.
*
* @param string $placeholder_path the placeholder path that should be converted
*/
@@ -103,9 +92,9 @@ class AttachmentURLGenerator
* Returns a URL under which the attachment file can be viewed.
* @return string|null The URL or null if the attachment file is not existing
*/
- public function getViewURL(Attachment $attachment): ?string
+ public function getInternalViewURL(Attachment $attachment): ?string
{
- $absolute_path = $this->attachmentHelper->toAbsoluteFilePath($attachment);
+ $absolute_path = $this->attachmentHelper->toAbsoluteInternalFilePath($attachment);
if (null === $absolute_path) {
return null;
}
@@ -121,7 +110,8 @@ class AttachmentURLGenerator
}
/**
- * Returns a URL to an thumbnail of the attachment file.
+ * Returns a URL to a thumbnail of the attachment file.
+ * For external files the original URL is returned.
* @return string|null The URL or null if the attachment file is not existing
*/
public function getThumbnailURL(Attachment $attachment, string $filter_name = 'thumbnail_sm'): ?string
@@ -130,11 +120,14 @@ class AttachmentURLGenerator
throw new InvalidArgumentException('Thumbnail creation only works for picture attachments!');
}
- if ($attachment->isExternal() && !empty($attachment->getURL())) {
- return $attachment->getURL();
+ if (!$attachment->hasInternal()){
+ if($attachment->hasExternal()) {
+ return $attachment->getExternalPath();
+ }
+ return null;
}
- $absolute_path = $this->attachmentHelper->toAbsoluteFilePath($attachment);
+ $absolute_path = $this->attachmentHelper->toAbsoluteInternalFilePath($attachment);
if (null === $absolute_path) {
return null;
}
@@ -145,19 +138,21 @@ class AttachmentURLGenerator
return $this->urlGenerator->generate('attachment_view', ['id' => $attachment->getID()]);
}
- //For builtin ressources it is not useful to create a thumbnail
- //because the footprints images are small and highly optimized already.
- if (('thumbnail_md' === $filter_name && $attachment->isBuiltIn())
- //GD can not work with SVG, so serve it directly...
- || 'svg' === $attachment->getExtension()) {
+ //GD can not work with SVG, so serve it directly...
+ //We can not use getExtension here, because it uses the original filename and not the real extension
+ //Instead we use the logic, which is also used to determine if the attachment is a picture
+ $extension = pathinfo(parse_url($attachment->getInternalPath(), PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
+ if ('svg' === $extension) {
return $this->assets->getUrl($asset_path);
}
try {
- //Otherwise we can serve the relative path via Asset component
- return $this->filterService->getUrlOfFilteredImage($asset_path, $filter_name);
- } catch (\Imagine\Exception\RuntimeException $e) {
- //If the filter fails, we can not serve the thumbnail and fall back to the original image and log an warning
+ //We try to get network path here (so no schema), but this param might just get ignored by the cache manager
+ $tmp = $this->thumbnailManager->getBrowserPath($asset_path, $filter_name, [], null, UrlGeneratorInterface::NETWORK_PATH);
+ //So we remove the schema manually
+ return preg_replace('/^https?:/', '', $tmp);
+ } catch (RuntimeException $e) {
+ //If the filter fails, we can not serve the thumbnail and fall back to the original image and log a warning
$this->logger->warning('Could not open thumbnail for attachment with ID ' . $attachment->getID() . ': ' . $e->getMessage());
return $this->assets->getUrl($asset_path);
}
@@ -166,7 +161,7 @@ class AttachmentURLGenerator
/**
* Returns a download link to the file associated with the attachment.
*/
- public function getDownloadURL(Attachment $attachment): string
+ public function getInternalDownloadURL(Attachment $attachment): string
{
//Redirect always to download controller, which sets the correct headers for downloading:
return $this->urlGenerator->generate('attachment_download', ['id' => $attachment->getID()]);
diff --git a/src/Services/Attachments/BuiltinAttachmentsFinder.php b/src/Services/Attachments/BuiltinAttachmentsFinder.php
index 7c3c8f4b..b009cf60 100644
--- a/src/Services/Attachments/BuiltinAttachmentsFinder.php
+++ b/src/Services/Attachments/BuiltinAttachmentsFinder.php
@@ -30,16 +30,12 @@ use Symfony\Contracts\Cache\CacheInterface;
/**
* This service is used to find builtin attachment ressources.
+ * @see \App\Tests\Services\Attachments\BuiltinAttachmentsFinderTest
*/
class BuiltinAttachmentsFinder
{
- protected AttachmentPathResolver $pathResolver;
- protected CacheInterface $cache;
-
- public function __construct(CacheInterface $cache, AttachmentPathResolver $pathResolver)
+ public function __construct(protected CacheInterface $cache, protected AttachmentPathResolver $pathResolver)
{
- $this->pathResolver = $pathResolver;
- $this->cache = $cache;
}
/**
@@ -49,7 +45,6 @@ class BuiltinAttachmentsFinder
* '%FOOTPRINTS%/path/to/folder/file1.png',
* '%FOOTPRINTS%/path/to/folder/file2.png',
* ]
- * @return array
*/
public function getListOfFootprintsGroupedByFolder(): array
{
@@ -63,7 +58,7 @@ class BuiltinAttachmentsFinder
foreach($finder as $file) {
$folder = $file->getRelativePath();
//Normalize path (replace \ with /)
- $folder = str_replace('\\', '/', $folder);
+ $folder = str_replace('\\', '/', (string) $folder);
if(!isset($output[$folder])) {
$output[$folder] = [];
@@ -109,7 +104,7 @@ class BuiltinAttachmentsFinder
return $results;
});
- } catch (InvalidArgumentException $invalidArgumentException) {
+ } catch (InvalidArgumentException) {
return [];
}
}
@@ -125,7 +120,7 @@ class BuiltinAttachmentsFinder
*/
public function find(string $keyword, array $options = [], ?array $base_list = []): array
{
- if (empty($base_list)) {
+ if ($base_list === null || $base_list === []) {
$base_list = $this->getListOfRessources();
}
diff --git a/src/Services/Attachments/FileTypeFilterTools.php b/src/Services/Attachments/FileTypeFilterTools.php
index bf44cbe1..d689fda3 100644
--- a/src/Services/Attachments/FileTypeFilterTools.php
+++ b/src/Services/Attachments/FileTypeFilterTools.php
@@ -29,9 +29,10 @@ use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
- * An servive that helps working with filetype filters (based on the format accept uses.
+ * A service that helps to work with filetype filters (based on the format accept uses).
* See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers for
* more details.
+ * @see \App\Tests\Services\Attachments\FileTypeFilterToolsTest
*/
class FileTypeFilterTools
{
@@ -43,13 +44,8 @@ class FileTypeFilterTools
protected const AUDIO_EXTS = ['mp3', 'flac', 'ogg', 'oga', 'wav', 'm4a', 'opus'];
protected const ALLOWED_MIME_PLACEHOLDERS = ['image/*', 'audio/*', 'video/*'];
- protected MimeTypesInterface $mimeTypes;
- protected CacheInterface $cache;
-
- public function __construct(MimeTypesInterface $mimeTypes, CacheInterface $cache)
+ public function __construct(protected MimeTypesInterface $mimeTypes, protected CacheInterface $cache)
{
- $this->mimeTypes = $mimeTypes;
- $this->cache = $cache;
}
/**
@@ -73,7 +69,7 @@ class FileTypeFilterTools
$element = trim($element);
if (!preg_match('#^\.\w+$#', $element) // .ext is allowed
&& !preg_match('#^[-\w.]+/[-\w.]+#', $element) //Explicit MIME type is allowed
- && !in_array($element, static::ALLOWED_MIME_PLACEHOLDERS, false)) { //image/* is allowed
+ && !in_array($element, static::ALLOWED_MIME_PLACEHOLDERS, true)) { //image/* is allowed
return false;
}
}
@@ -108,7 +104,7 @@ class FileTypeFilterTools
}
//Convert *.jpg to .jpg
- if (0 === strpos($element, '*.')) {
+ if (str_starts_with($element, '*.')) {
$element = str_replace('*.', '.', $element);
}
@@ -119,11 +115,13 @@ class FileTypeFilterTools
$element = 'video/*';
} elseif ('audio' === $element || 'audio/' === $element) {
$element = 'audio/*';
- } elseif (!preg_match('#^[-\w.]+/[-\w.*]+#', $element) && 0 !== strpos($element, '.')) {
+ } elseif (!preg_match('#^[-\w.]+/[-\w.*]+#', $element) && !str_starts_with($element, '.')) {
//Convert jpg to .jpg
$element = '.'.$element;
}
}
+ //Prevent weird side effects
+ unset($element);
$elements = array_unique($elements);
@@ -147,7 +145,7 @@ class FileTypeFilterTools
foreach ($elements as $element) {
$element = trim($element);
- if (0 === strpos($element, '.')) {
+ if (str_starts_with($element, '.')) {
//We found an explicit specified file extension -> add it to list
$extensions[] = substr($element, 1);
} elseif ('image/*' === $element) {
@@ -177,6 +175,6 @@ class FileTypeFilterTools
{
$extension = strtolower($extension);
- return empty($filter) || in_array($extension, $this->resolveFileExtensions($filter), false);
+ return $filter === '' || in_array($extension, $this->resolveFileExtensions($filter), true);
}
}
diff --git a/src/Services/Attachments/PartPreviewGenerator.php b/src/Services/Attachments/PartPreviewGenerator.php
index 39d1c65c..ba6e5db0 100644
--- a/src/Services/Attachments/PartPreviewGenerator.php
+++ b/src/Services/Attachments/PartPreviewGenerator.php
@@ -22,16 +22,19 @@ declare(strict_types=1);
namespace App\Services\Attachments;
+use App\Entity\Parts\Footprint;
+use App\Entity\ProjectSystem\Project;
+use App\Entity\Parts\Category;
+use App\Entity\Parts\StorageLocation;
+use App\Entity\Parts\MeasurementUnit;
+use App\Entity\Parts\Manufacturer;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Part;
class PartPreviewGenerator
{
- protected AttachmentManager $attachmentHelper;
-
- public function __construct(AttachmentManager $attachmentHelper)
+ public function __construct(protected AttachmentManager $attachmentHelper)
{
- $this->attachmentHelper = $attachmentHelper;
}
/**
@@ -55,21 +58,29 @@ class PartPreviewGenerator
$list[] = $attachment;
}
- if (null !== $part->getFootprint()) {
+ //Then comes the other images of the part
+ foreach ($part->getAttachments() as $attachment) {
+ //Dont show the master attachment twice
+ if ($this->isAttachmentValidPicture($attachment) && $attachment !== $part->getMasterPictureAttachment()) {
+ $list[] = $attachment;
+ }
+ }
+
+ if ($part->getFootprint() instanceof Footprint) {
$attachment = $part->getFootprint()->getMasterPictureAttachment();
if ($this->isAttachmentValidPicture($attachment)) {
$list[] = $attachment;
}
}
- if (null !== $part->getBuiltProject()) {
+ if ($part->getBuiltProject() instanceof Project) {
$attachment = $part->getBuiltProject()->getMasterPictureAttachment();
if ($this->isAttachmentValidPicture($attachment)) {
$list[] = $attachment;
}
}
- if (null !== $part->getCategory()) {
+ if ($part->getCategory() instanceof Category) {
$attachment = $part->getCategory()->getMasterPictureAttachment();
if ($this->isAttachmentValidPicture($attachment)) {
$list[] = $attachment;
@@ -77,7 +88,7 @@ class PartPreviewGenerator
}
foreach ($part->getPartLots() as $lot) {
- if (null !== $lot->getStorageLocation()) {
+ if ($lot->getStorageLocation() instanceof StorageLocation) {
$attachment = $lot->getStorageLocation()->getMasterPictureAttachment();
if ($this->isAttachmentValidPicture($attachment)) {
$list[] = $attachment;
@@ -85,14 +96,14 @@ class PartPreviewGenerator
}
}
- if (null !== $part->getPartUnit()) {
+ if ($part->getPartUnit() instanceof MeasurementUnit) {
$attachment = $part->getPartUnit()->getMasterPictureAttachment();
if ($this->isAttachmentValidPicture($attachment)) {
$list[] = $attachment;
}
}
- if (null !== $part->getManufacturer()) {
+ if ($part->getManufacturer() instanceof Manufacturer) {
$attachment = $part->getManufacturer()->getMasterPictureAttachment();
if ($this->isAttachmentValidPicture($attachment)) {
$list[] = $attachment;
@@ -117,7 +128,7 @@ class PartPreviewGenerator
}
//Otherwise check if the part has a footprint with a valid master attachment
- if (null !== $part->getFootprint()) {
+ if ($part->getFootprint() instanceof Footprint) {
$attachment = $part->getFootprint()->getMasterPictureAttachment();
if ($this->isAttachmentValidPicture($attachment)) {
return $attachment;
@@ -125,7 +136,7 @@ class PartPreviewGenerator
}
//With lowest priority use the master attachment of the project this part represents (when existing)
- if (null !== $part->getBuiltProject()) {
+ if ($part->getBuiltProject() instanceof Project) {
$attachment = $part->getBuiltProject()->getMasterPictureAttachment();
if ($this->isAttachmentValidPicture($attachment)) {
return $attachment;
@@ -145,7 +156,7 @@ class PartPreviewGenerator
*/
protected function isAttachmentValidPicture(?Attachment $attachment): bool
{
- return null !== $attachment
+ return $attachment instanceof Attachment
&& $attachment->isPicture()
&& $this->attachmentHelper->isFileExisting($attachment);
}
diff --git a/src/Services/Attachments/SVGSanitizer.php b/src/Services/Attachments/SVGSanitizer.php
new file mode 100644
index 00000000..9ac5956b
--- /dev/null
+++ b/src/Services/Attachments/SVGSanitizer.php
@@ -0,0 +1,58 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\Attachments;
+
+use Rhukster\DomSanitizer\DOMSanitizer;
+
+class SVGSanitizer
+{
+
+ /**
+ * Sanitizes the given SVG string by removing any potentially harmful content (like inline scripts).
+ * @param string $input
+ * @return string
+ */
+ public function sanitizeString(string $input): string
+ {
+ return (new DOMSanitizer(DOMSanitizer::SVG))->sanitize($input);
+ }
+
+ /**
+ * Sanitizes the given SVG file by removing any potentially harmful content (like inline scripts).
+ * The sanitized content is written back to the file.
+ * @param string $filepath
+ */
+ public function sanitizeFile(string $filepath): void
+ {
+ //Open the file and read the content
+ $content = file_get_contents($filepath);
+ if ($content === false) {
+ throw new \RuntimeException('Could not read file: ' . $filepath);
+ }
+ //Sanitize the content
+ $sanitizedContent = $this->sanitizeString($content);
+ //Write the sanitized content back to the file
+ file_put_contents($filepath, $sanitizedContent);
+ }
+}
\ No newline at end of file
diff --git a/src/Services/Cache/ElementCacheTagGenerator.php b/src/Services/Cache/ElementCacheTagGenerator.php
new file mode 100644
index 00000000..88fca09f
--- /dev/null
+++ b/src/Services/Cache/ElementCacheTagGenerator.php
@@ -0,0 +1,69 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\Cache;
+
+use Doctrine\Persistence\Proxy;
+
+/**
+ * The purpose of this class is to generate cache tags for elements.
+ * E.g. to easily invalidate all caches for a given element type.
+ */
+class ElementCacheTagGenerator
+{
+ private array $cache = [];
+
+ public function __construct()
+ {
+ }
+
+ /**
+ * Returns a cache tag for the given element type, which can be used to invalidate all caches for this element type.
+ * @param string|object $element
+ * @return string
+ */
+ public function getElementTypeCacheTag(string|object $element): string
+ {
+ //Ensure that the given element is a class name
+ if (is_object($element)) {
+ $element = $element::class;
+ } elseif (!class_exists($element)) {
+ //And that the class exists
+ throw new \InvalidArgumentException("The given class '$element' does not exist!");
+ }
+
+ //Check if the tag is already cached
+ if (isset($this->cache[$element])) {
+ return $this->cache[$element];
+ }
+
+ //If the element is a proxy, then get the real class name of the underlying object
+ if (is_a($element, Proxy::class, true) || str_starts_with($element, 'Proxies\\')) {
+ $element = get_parent_class($element);
+ }
+
+ //Replace all backslashes with underscores to prevent problems with the cache and save the result
+ $this->cache[$element] = str_replace('\\', '_', $element);
+ return $this->cache[$element];
+ }
+}
\ No newline at end of file
diff --git a/src/Services/UserSystem/UserCacheKeyGenerator.php b/src/Services/Cache/UserCacheKeyGenerator.php
similarity index 78%
rename from src/Services/UserSystem/UserCacheKeyGenerator.php
rename to src/Services/Cache/UserCacheKeyGenerator.php
index c7c9e737..ac5487a5 100644
--- a/src/Services/UserSystem/UserCacheKeyGenerator.php
+++ b/src/Services/Cache/UserCacheKeyGenerator.php
@@ -20,25 +20,21 @@
declare(strict_types=1);
-namespace App\Services\UserSystem;
+namespace App\Services\Cache;
use App\Entity\UserSystem\User;
use Locale;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
-use Symfony\Component\Security\Core\Security;
/**
* Purpose of this service is to generate a key unique for a user, to use in Cache keys and tags.
*/
class UserCacheKeyGenerator
{
- protected Security $security;
- protected RequestStack $requestStack;
-
- public function __construct(Security $security, RequestStack $requestStack)
+ public function __construct(protected Security $security, protected RequestStack $requestStack)
{
- $this->security = $security;
- $this->requestStack = $requestStack;
}
/**
@@ -51,10 +47,10 @@ class UserCacheKeyGenerator
{
$request = $this->requestStack->getCurrentRequest();
//Retrieve the locale from the request, if possible, otherwise use the default locale
- $locale = $request ? $request->getLocale() : Locale::getDefault();
+ $locale = $request instanceof Request ? $request->getLocale() : Locale::getDefault();
//If no user was specified, use the currently used one.
- if (null === $user) {
+ if (!$user instanceof User) {
$user = $this->security->getUser();
}
@@ -64,7 +60,7 @@ class UserCacheKeyGenerator
return 'user$_'.User::ID_ANONYMOUS;
}
- //In the most cases we can just use the username (its unique)
- return 'user_'.$user->getUsername().'_'.$locale;
+ //Use the unique user id and the locale to generate the key
+ return 'user_'.$user->getID().'_'.$locale;
}
}
diff --git a/src/Services/CustomEnvVarProcessor.php b/src/Services/CustomEnvVarProcessor.php
index 8969b765..f269cc7d 100644
--- a/src/Services/CustomEnvVarProcessor.php
+++ b/src/Services/CustomEnvVarProcessor.php
@@ -35,7 +35,7 @@ final class CustomEnvVarProcessor implements EnvVarProcessorInterface
$env = $getEnv($name);
return !empty($env) && 'null://null' !== $env;
- } catch (EnvNotFoundException $envNotFoundException) {
+ } catch (EnvNotFoundException) {
return false;
}
}
diff --git a/src/Services/Misc/DBInfoHelper.php b/src/Services/Doctrine/DBInfoHelper.php
similarity index 56%
rename from src/Services/Misc/DBInfoHelper.php
rename to src/Services/Doctrine/DBInfoHelper.php
index 896c0637..160e2d89 100644
--- a/src/Services/Misc/DBInfoHelper.php
+++ b/src/Services/Doctrine/DBInfoHelper.php
@@ -1,4 +1,25 @@
.
+ */
+
+declare(strict_types=1);
+
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
@@ -17,12 +38,13 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
-
-namespace App\Services\Misc;
+namespace App\Services\Doctrine;
use Doctrine\DBAL\Connection;
+use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
-use Doctrine\DBAL\Platforms\SqlitePlatform;
+use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
+use Doctrine\DBAL\Platforms\SQLitePlatform;
use Doctrine\ORM\EntityManagerInterface;
/**
@@ -31,11 +53,9 @@ use Doctrine\ORM\EntityManagerInterface;
class DBInfoHelper
{
protected Connection $connection;
- protected EntityManagerInterface $entityManager;
- public function __construct(EntityManagerInterface $entityManager)
+ public function __construct(protected EntityManagerInterface $entityManager)
{
- $this->entityManager = $entityManager;
$this->connection = $entityManager->getConnection();
}
@@ -49,17 +69,20 @@ class DBInfoHelper
return 'mysql';
}
- if ($this->connection->getDatabasePlatform() instanceof SqlitePlatform) {
+ if ($this->connection->getDatabasePlatform() instanceof SQLitePlatform) {
return 'sqlite';
}
+ if ($this->connection->getDatabasePlatform() instanceof PostgreSqlPlatform) {
+ return 'postgresql';
+ }
+
return null;
}
/**
* Returns the database version of the used database.
- * @return string|null
- * @throws \Doctrine\DBAL\Exception
+ * @throws Exception
*/
public function getDatabaseVersion(): ?string
{
@@ -67,32 +90,44 @@ class DBInfoHelper
return $this->connection->fetchOne('SELECT VERSION()');
}
- if ($this->connection->getDatabasePlatform() instanceof SqlitePlatform) {
+ if ($this->connection->getDatabasePlatform() instanceof SQLitePlatform) {
return $this->connection->fetchOne('SELECT sqlite_version()');
}
+ if ($this->connection->getDatabasePlatform() instanceof PostgreSqlPlatform) {
+ return $this->connection->fetchOne('SELECT version()');
+ }
+
return null;
}
/**
* Returns the database size in bytes.
* @return int|null The database size in bytes or null if unknown
- * @throws \Doctrine\DBAL\Exception
+ * @throws Exception
*/
public function getDatabaseSize(): ?int
{
if ($this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform) {
try {
- return $this->connection->fetchOne('SELECT SUM(data_length + index_length) FROM information_schema.TABLES WHERE table_schema = DATABASE()');
- } catch (\Doctrine\DBAL\Exception $e) {
+ return (int) $this->connection->fetchOne('SELECT SUM(data_length + index_length) FROM information_schema.TABLES WHERE table_schema = DATABASE()');
+ } catch (Exception) {
return null;
}
}
- if ($this->connection->getDatabasePlatform() instanceof SqlitePlatform) {
+ if ($this->connection->getDatabasePlatform() instanceof SQLitePlatform) {
try {
- return $this->connection->fetchOne('SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size();');
- } catch (\Doctrine\DBAL\Exception $e) {
+ return (int) $this->connection->fetchOne('SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size();');
+ } catch (Exception) {
+ return null;
+ }
+ }
+
+ if ($this->connection->getDatabasePlatform() instanceof PostgreSqlPlatform) {
+ try {
+ return (int) $this->connection->fetchOne('SELECT pg_database_size(current_database())');
+ } catch (Exception) {
return null;
}
}
@@ -102,30 +137,36 @@ class DBInfoHelper
/**
* Returns the name of the database.
- * @return string|null
*/
public function getDatabaseName(): ?string
{
- return $this->connection->getDatabase() ?? null;
+ return $this->connection->getDatabase();
}
/**
* Returns the name of the database user.
- * @return string|null
*/
public function getDatabaseUsername(): ?string
{
if ($this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform) {
try {
return $this->connection->fetchOne('SELECT USER()');
- } catch (\Doctrine\DBAL\Exception $e) {
+ } catch (Exception) {
return null;
}
}
- if ($this->connection->getDatabasePlatform() instanceof SqlitePlatform) {
+ if ($this->connection->getDatabasePlatform() instanceof SQLitePlatform) {
return 'sqlite';
}
+
+ if ($this->connection->getDatabasePlatform() instanceof PostgreSqlPlatform) {
+ try {
+ return $this->connection->fetchOne('SELECT current_user');
+ } catch (Exception) {
+ return null;
+ }
+ }
return null;
}
diff --git a/src/Services/Doctrine/NatsortDebugHelper.php b/src/Services/Doctrine/NatsortDebugHelper.php
new file mode 100644
index 00000000..fe5b77aa
--- /dev/null
+++ b/src/Services/Doctrine/NatsortDebugHelper.php
@@ -0,0 +1,86 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\Doctrine;
+
+use App\Doctrine\Functions\Natsort;
+use App\Entity\Parts\Part;
+use Doctrine\ORM\EntityManagerInterface;
+
+/**
+ * This service allows to debug the natsort function by showing various information about the current state of
+ * the natsort function.
+ */
+class NatsortDebugHelper
+{
+ public function __construct(private readonly EntityManagerInterface $entityManager)
+ {
+ // This is a dummy constructor
+ }
+
+ /**
+ * Check if the slow natural sort is allowed on the Natsort function.
+ * If it is not, then the request handler might need to be adjusted.
+ * @return bool
+ */
+ public function isSlowNaturalSortAllowed(): bool
+ {
+ return Natsort::isSlowNaturalSortAllowed();
+ }
+
+ public function getNaturalSortMethod(): string
+ {
+ //Construct a dummy query which uses the Natsort function
+ $query = $this->entityManager->createQuery('SELECT natsort(1) FROM ' . Part::class . ' p');
+ $sql = $query->getSQL();
+ //Remove the leading SELECT and the trailing semicolon
+ $sql = substr($sql, 7, -1);
+
+ //Remove AS and everything afterwards
+ $sql = preg_replace('/\s+AS\s+.*/', '', $sql);
+
+ //If just 1 is returned, then we use normal (non-natural sorting)
+ if ($sql === '1') {
+ return 'Disabled';
+ }
+
+ if (str_contains( $sql, 'COLLATE numeric')) {
+ return 'Native (PostgreSQL)';
+ }
+
+ if (str_contains($sql, 'NATURAL_SORT_KEY')) {
+ return 'Native (MariaDB)';
+ }
+
+ if (str_contains($sql, 'COLLATE NATURAL_CMP')) {
+ return 'Emulation via PHP (SQLite)';
+ }
+
+ if (str_contains($sql, 'NatSortKey')) {
+ return 'Emulation via custom function (MySQL)';
+ }
+
+
+ return 'Unknown ('. $sql . ')';
+ }
+}
\ No newline at end of file
diff --git a/src/Services/EDA/KiCadHelper.php b/src/Services/EDA/KiCadHelper.php
new file mode 100644
index 00000000..d4cbab34
--- /dev/null
+++ b/src/Services/EDA/KiCadHelper.php
@@ -0,0 +1,347 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\EDA;
+
+use App\Entity\Parts\Category;
+use App\Entity\Parts\Footprint;
+use App\Entity\Parts\Part;
+use App\Services\Cache\ElementCacheTagGenerator;
+use App\Services\EntityURLGenerator;
+use App\Services\Trees\NodesListBuilder;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Contracts\Cache\ItemInterface;
+use Symfony\Contracts\Cache\TagAwareCacheInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+class KiCadHelper
+{
+
+ public function __construct(
+ private readonly NodesListBuilder $nodesListBuilder,
+ private readonly TagAwareCacheInterface $kicadCache,
+ private readonly EntityManagerInterface $em,
+ private readonly ElementCacheTagGenerator $tagGenerator,
+ private readonly UrlGeneratorInterface $urlGenerator,
+ private readonly EntityURLGenerator $entityURLGenerator,
+ private readonly TranslatorInterface $translator,
+ /** The maximum level of the shown categories. 0 Means only the top level categories are shown. -1 means only a single one containing */
+ private readonly int $category_depth,
+ ) {
+ }
+
+ /**
+ * Returns an array of objects containing all categories in the database in the format required by KiCAD.
+ * The categories are flattened and sorted by their full path.
+ * Categories, which contain no parts, are filtered out.
+ * The result is cached for performance and invalidated on category changes.
+ * @return array
+ */
+ public function getCategories(): array
+ {
+ return $this->kicadCache->get('kicad_categories_' . $this->category_depth, function (ItemInterface $item) {
+ //Invalidate the cache on category changes
+ $secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Category::class);
+ $item->tag($secure_class_name);
+
+ //Invalidate the cache on part changes (as the visibility depends on parts, and the parts can change)
+ $secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Part::class);
+ $item->tag($secure_class_name);
+
+ //If the category depth is smaller than 0, create only one dummy category
+ if ($this->category_depth < 0) {
+ return [
+ [
+ 'id' => '0',
+ 'name' => 'Part-DB',
+ ]
+ ];
+ }
+
+ //Otherwise just get the categories and filter them
+
+ $categories = $this->nodesListBuilder->typeToNodesList(Category::class);
+ $repo = $this->em->getRepository(Category::class);
+ $result = [];
+ foreach ($categories as $category) {
+ //Skip invisible categories
+ if ($category->getEdaInfo()->getVisibility() === false) {
+ continue;
+ }
+
+ //Skip categories with a depth greater than the configured one
+ if ($category->getLevel() > $this->category_depth) {
+ continue;
+ }
+
+ //Ensure that the category contains parts
+ //For the last level, we need to use a recursive query, otherwise we can use a simple query
+ /** @var Category $category */
+ $parts_count = $category->getLevel() >= $this->category_depth ? $repo->getPartsCountRecursive($category) : $repo->getPartsCount($category);
+
+ if ($parts_count < 1) {
+ continue;
+ }
+
+ //Check if the category should be visible
+ if (!$this->shouldCategoryBeVisible($category)) {
+ continue;
+ }
+
+ //Format the category for KiCAD
+ $result[] = [
+ 'id' => (string)$category->getId(),
+ 'name' => $category->getFullPath('/'),
+ //Show the category link as the category description, this also fixes an segfault in KiCad see issue #878
+ 'description' => $this->entityURLGenerator->listPartsURL($category),
+ ];
+ }
+
+ return $result;
+ });
+ }
+
+ /**
+ * Returns an array of objects containing all parts for the given category in the format required by KiCAD.
+ * The result is cached for performance and invalidated on category or part changes.
+ * @param Category|null $category
+ * @return array
+ */
+ public function getCategoryParts(?Category $category): array
+ {
+ return $this->kicadCache->get('kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth,
+ function (ItemInterface $item) use ($category) {
+ $item->tag([
+ $this->tagGenerator->getElementTypeCacheTag(Category::class),
+ $this->tagGenerator->getElementTypeCacheTag(Part::class),
+ //Visibility can change based on the footprint
+ $this->tagGenerator->getElementTypeCacheTag(Footprint::class)
+ ]);
+
+ if ($this->category_depth >= 0) {
+ //Ensure that the category is set
+ if ($category === null) {
+ throw new NotFoundHttpException('Category must be set, if category_depth is greater than 1!');
+ }
+
+ $category_repo = $this->em->getRepository(Category::class);
+ if ($category->getLevel() >= $this->category_depth) {
+ //Get all parts for the category and its children
+ $parts = $category_repo->getPartsRecursive($category);
+ } else {
+ //Get only direct parts for the category (without children), as the category is not collapsed
+ $parts = $category_repo->getParts($category);
+ }
+ } else {
+ //Get all parts
+ $parts = $this->em->getRepository(Part::class)->findAll();
+ }
+
+ $result = [];
+ foreach ($parts as $part) {
+ //If the part is invisible, then skip it
+ if (!$this->shouldPartBeVisible($part)) {
+ continue;
+ }
+
+ $result[] = [
+ 'id' => (string)$part->getId(),
+ 'name' => $part->getName(),
+ 'description' => $part->getDescription(),
+ ];
+ }
+
+ return $result;
+ });
+ }
+
+ public function getKiCADPart(Part $part): array
+ {
+ $result = [
+ 'id' => (string)$part->getId(),
+ 'name' => $part->getName(),
+ "symbolIdStr" => $part->getEdaInfo()->getKicadSymbol() ?? $part->getCategory()?->getEdaInfo()->getKicadSymbol() ?? "",
+ "exclude_from_bom" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBom() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBom() ?? false),
+ "exclude_from_board" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBoard() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBoard() ?? false),
+ "exclude_from_sim" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromSim() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromSim() ?? true),
+ "fields" => []
+ ];
+
+ $result["fields"]["footprint"] = $this->createField($part->getEdaInfo()->getKicadFootprint() ?? $part->getFootprint()?->getEdaInfo()->getKicadFootprint() ?? "");
+ $result["fields"]["reference"] = $this->createField($part->getEdaInfo()->getReferencePrefix() ?? $part->getCategory()?->getEdaInfo()->getReferencePrefix() ?? 'U', true);
+ $result["fields"]["value"] = $this->createField($part->getEdaInfo()->getValue() ?? $part->getName(), true);
+ $result["fields"]["keywords"] = $this->createField($part->getTags());
+
+ //Use the part info page as datasheet link. It must be an absolute URL.
+ $result["fields"]["datasheet"] = $this->createField(
+ $this->urlGenerator->generate(
+ 'part_info',
+ ['id' => $part->getId()],
+ UrlGeneratorInterface::ABSOLUTE_URL)
+ );
+
+ //Add basic fields
+ $result["fields"]["description"] = $this->createField($part->getDescription());
+ if ($part->getCategory() !== null) {
+ $result["fields"]["Category"] = $this->createField($part->getCategory()->getFullPath('/'));
+ }
+ if ($part->getManufacturer() !== null) {
+ $result["fields"]["Manufacturer"] = $this->createField($part->getManufacturer()->getName());
+ }
+ if ($part->getManufacturerProductNumber() !== "") {
+ $result['fields']["MPN"] = $this->createField($part->getManufacturerProductNumber());
+ }
+ if ($part->getManufacturingStatus() !== null) {
+ $result["fields"]["Manufacturing Status"] = $this->createField(
+ //Always use the english translation
+ $this->translator->trans($part->getManufacturingStatus()->toTranslationKey(), locale: 'en')
+ );
+ }
+ if ($part->getFootprint() !== null) {
+ $result["fields"]["Part-DB Footprint"] = $this->createField($part->getFootprint()->getName());
+ }
+ if ($part->getPartUnit() !== null) {
+ $unit = $part->getPartUnit()->getName();
+ if ($part->getPartUnit()->getUnit() !== "") {
+ $unit .= ' ('.$part->getPartUnit()->getUnit().')';
+ }
+ $result["fields"]["Part-DB Unit"] = $this->createField($unit);
+ }
+ if ($part->getMass()) {
+ $result["fields"]["Mass"] = $this->createField($part->getMass() . ' g');
+ }
+ $result["fields"]["Part-DB ID"] = $this->createField($part->getId());
+ if ($part->getIpn() !== null && $part->getIpn() !== '' && $part->getIpn() !== '0') {
+ $result["fields"]["Part-DB IPN"] = $this->createField($part->getIpn());
+ }
+
+
+ return $result;
+ }
+
+ /**
+ * Determine if the given part should be visible for the EDA.
+ * @param Category $category
+ * @return bool
+ */
+ private function shouldCategoryBeVisible(Category $category): bool
+ {
+ $eda_info = $category->getEdaInfo();
+
+ //If the category visibility is explicitly set, then use it
+ if ($eda_info->getVisibility() !== null) {
+ return $eda_info->getVisibility();
+ }
+
+ //try to check if the fields were set
+ if ($eda_info->getKicadSymbol() !== null
+ || $eda_info->getReferencePrefix() !== null) {
+ return true;
+ }
+
+ //Check if there is any part in this category, which should be visible
+ $category_repo = $this->em->getRepository(Category::class);
+ if ($category->getLevel() >= $this->category_depth) {
+ //Get all parts for the category and its children
+ $parts = $category_repo->getPartsRecursive($category);
+ } else {
+ //Get only direct parts for the category (without children), as the category is not collapsed
+ $parts = $category_repo->getParts($category);
+ }
+
+ foreach ($parts as $part) {
+ if ($this->shouldPartBeVisible($part)) {
+ return true;
+ }
+ }
+
+ //Otherwise the category should be not visible
+ return false;
+ }
+
+ /**
+ * Determine if the given part should be visible for the EDA.
+ * @param Part $part
+ * @return bool
+ */
+ private function shouldPartBeVisible(Part $part): bool
+ {
+ $eda_info = $part->getEdaInfo();
+ $category = $part->getCategory();
+
+ //If the user set a visibility, then use it
+ if ($eda_info->getVisibility() !== null) {
+ return $part->getEdaInfo()->getVisibility();
+ }
+
+ //If the part has a category, then use the category visibility if possible
+ if ($category && $category->getEdaInfo()->getVisibility() !== null) {
+ return $category->getEdaInfo()->getVisibility();
+ }
+
+ //If both are null, then we try to determine the visibility based on if fields are set
+ if ($eda_info->getKicadSymbol() !== null
+ || $eda_info->getKicadFootprint() !== null
+ || $eda_info->getReferencePrefix() !== null
+ || $eda_info->getValue() !== null) {
+ return true;
+ }
+
+ //Check also if the fields are set for the category (if it exists)
+ if ($category && (
+ $category->getEdaInfo()->getKicadSymbol() !== null
+ || $category->getEdaInfo()->getReferencePrefix() !== null
+ )) {
+ return true;
+ }
+ //And on the footprint
+ //Otherwise the part should be not visible
+ return $part->getFootprint() && $part->getFootprint()->getEdaInfo()->getKicadFootprint() !== null;
+ }
+
+ /**
+ * Converts a boolean value to the format required by KiCAD.
+ * @param bool $value
+ * @return string
+ */
+ private function boolToKicadBool(bool $value): string
+ {
+ return $value ? 'True' : 'False';
+ }
+
+ /**
+ * Creates a field array for KiCAD
+ * @param string|int|float $value
+ * @param bool $visible
+ * @return array
+ */
+ private function createField(string|int|float $value, bool $visible = false): array
+ {
+ return [
+ 'value' => (string)$value,
+ 'visible' => $this->boolToKicadBool($visible),
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/Services/ElementTypeNameGenerator.php b/src/Services/ElementTypeNameGenerator.php
index adee61ad..14247145 100644
--- a/src/Services/ElementTypeNameGenerator.php
+++ b/src/Services/ElementTypeNameGenerator.php
@@ -22,9 +22,12 @@ declare(strict_types=1);
namespace App\Services;
+use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
+use App\Entity\Base\AbstractDBElement;
use App\Entity\Contracts\NamedElementInterface;
+use App\Entity\Parts\PartAssociation;
use App\Entity\ProjectSystem\Project;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parameters\AbstractParameter;
@@ -34,7 +37,7 @@ use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
-use App\Entity\Parts\Storelocation;
+use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\PriceInformations\Orderdetail;
@@ -43,18 +46,17 @@ use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use App\Exceptions\EntityNotSupportedException;
-use function get_class;
use Symfony\Contracts\Translation\TranslatorInterface;
+/**
+ * @see \App\Tests\Services\ElementTypeNameGeneratorTest
+ */
class ElementTypeNameGenerator
{
- protected TranslatorInterface $translator;
protected array $mapping;
- public function __construct(TranslatorInterface $translator)
+ public function __construct(protected TranslatorInterface $translator, private readonly EntityURLGenerator $entityURLGenerator)
{
- $this->translator = $translator;
-
//Child classes has to become before parent classes
$this->mapping = [
Attachment::class => $this->translator->trans('attachment.label'),
@@ -67,7 +69,7 @@ class ElementTypeNameGenerator
MeasurementUnit::class => $this->translator->trans('measurement_unit.label'),
Part::class => $this->translator->trans('part.label'),
PartLot::class => $this->translator->trans('part_lot.label'),
- Storelocation::class => $this->translator->trans('storelocation.label'),
+ StorageLocation::class => $this->translator->trans('storelocation.label'),
Supplier::class => $this->translator->trans('supplier.label'),
Currency::class => $this->translator->trans('currency.label'),
Orderdetail::class => $this->translator->trans('orderdetail.label'),
@@ -76,11 +78,12 @@ class ElementTypeNameGenerator
User::class => $this->translator->trans('user.label'),
AbstractParameter::class => $this->translator->trans('parameter.label'),
LabelProfile::class => $this->translator->trans('label_profile.label'),
+ PartAssociation::class => $this->translator->trans('part_association.label'),
];
}
/**
- * Gets an localized label for the type of the entity.
+ * Gets a localized label for the type of the entity.
* A part element becomes "Part" ("Bauteil" in german) and a category object becomes "Category".
* Useful when the type should be shown to user.
* Throws an exception if the class is not supported.
@@ -91,24 +94,24 @@ class ElementTypeNameGenerator
*
* @throws EntityNotSupportedException when the passed entity is not supported
*/
- public function getLocalizedTypeLabel($entity): string
+ public function getLocalizedTypeLabel(object|string $entity): string
{
- $class = is_string($entity) ? $entity : get_class($entity);
+ $class = is_string($entity) ? $entity : $entity::class;
- //Check if we have an direct array entry for our entity class, then we can use it
+ //Check if we have a direct array entry for our entity class, then we can use it
if (isset($this->mapping[$class])) {
return $this->mapping[$class];
}
//Otherwise iterate over array and check for inheritance (needed when the proxy element from doctrine are passed)
- foreach ($this->mapping as $class => $translation) {
- if (is_a($entity, $class, true)) {
+ foreach ($this->mapping as $class_to_check => $translation) {
+ if (is_a($entity, $class_to_check, true)) {
return $translation;
}
}
//When nothing was found throw an exception
- throw new EntityNotSupportedException(sprintf('No localized label for the element with type %s was found!', is_object($entity) ? get_class($entity) : (string) $entity));
+ throw new EntityNotSupportedException(sprintf('No localized label for the element with type %s was found!', is_object($entity) ? $entity::class : (string) $entity));
}
/**
@@ -117,7 +120,7 @@ class ElementTypeNameGenerator
* It uses getLocalizedLabel to determine the type.
*
* @param NamedElementInterface $entity the entity for which the string should be generated
- * @param bool $use_html If set to true, a html string is returned, where the type is set italic
+ * @param bool $use_html If set to true, a html string is returned, where the type is set italic, and the name is escaped
*
* @return string The localized string
*
@@ -132,4 +135,77 @@ class ElementTypeNameGenerator
return $type.': '.$entity->getName();
}
+
+
+ /**
+ * Returns a HTML formatted label for the given enitity in the format "Type: Name" (on elements with a name) and
+ * "Type: ID" (on elements without a name). If possible the value is given as a link to the element.
+ * @param AbstractDBElement $entity The entity for which the label should be generated
+ * @param bool $include_associated If set to true, the associated entity (like the part belonging to a part lot) is included in the label to give further information
+ */
+ public function formatLabelHTMLForEntity(AbstractDBElement $entity, bool $include_associated = false): string
+ {
+ //The element is existing
+ if ($entity instanceof NamedElementInterface && $entity->getName() !== '') {
+ try {
+ $tmp = sprintf(
+ '%s',
+ $this->entityURLGenerator->infoURL($entity),
+ $this->getTypeNameCombination($entity, true)
+ );
+ } catch (EntityNotSupportedException) {
+ $tmp = $this->getTypeNameCombination($entity, true);
+ }
+ } else { //Target does not have a name
+ $tmp = sprintf(
+ '%s: %s',
+ $this->getLocalizedTypeLabel($entity),
+ $entity->getID()
+ );
+ }
+
+ //Add a hint to the associated element if possible
+ if ($include_associated) {
+ if ($entity instanceof Attachment && $entity->getElement() instanceof AttachmentContainingDBElement) {
+ $on = $entity->getElement();
+ } elseif ($entity instanceof AbstractParameter && $entity->getElement() instanceof AbstractDBElement) {
+ $on = $entity->getElement();
+ } elseif ($entity instanceof PartLot && $entity->getPart() instanceof Part) {
+ $on = $entity->getPart();
+ } elseif ($entity instanceof Orderdetail && $entity->getPart() instanceof Part) {
+ $on = $entity->getPart();
+ } elseif ($entity instanceof Pricedetail && $entity->getOrderdetail() instanceof Orderdetail && $entity->getOrderdetail()->getPart() instanceof Part) {
+ $on = $entity->getOrderdetail()->getPart();
+ } elseif ($entity instanceof ProjectBOMEntry && $entity->getProject() instanceof Project) {
+ $on = $entity->getProject();
+ }
+
+ if (isset($on) && $on instanceof NamedElementInterface) {
+ try {
+ $tmp .= sprintf(
+ ' (%s)',
+ $this->entityURLGenerator->infoURL($on),
+ $this->getTypeNameCombination($on, true)
+ );
+ } catch (EntityNotSupportedException) {
+ }
+ }
+ }
+
+ return $tmp;
+ }
+
+ /**
+ * Create a HTML formatted label for a deleted element of which we only know the class and the ID.
+ * Please note that it is not checked if the element really not exists anymore, so you have to do this yourself.
+ */
+ public function formatElementDeletedHTML(string $class, int $id): string
+ {
+ return sprintf(
+ '%s: %s [%s]',
+ $this->getLocalizedTypeLabel($class),
+ $id,
+ $this->translator->trans('log.target_deleted')
+ );
+ }
}
diff --git a/src/Services/EntityMergers/EntityMerger.php b/src/Services/EntityMergers/EntityMerger.php
new file mode 100644
index 00000000..c0be84ee
--- /dev/null
+++ b/src/Services/EntityMergers/EntityMerger.php
@@ -0,0 +1,76 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\EntityMergers;
+
+use App\Services\EntityMergers\Mergers\EntityMergerInterface;
+use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
+
+/**
+ * This service is used to merge two entities together.
+ * It automatically finds the correct merger (implementing EntityMergerInterface) for the two entities if one exists.
+ */
+class EntityMerger
+{
+ public function __construct(#[TaggedIterator('app.entity_merger')] protected iterable $mergers)
+ {
+ }
+
+ /**
+ * This function finds the first merger that supports merging the other entity into the target entity.
+ * @param object $target
+ * @param object $other
+ * @param array $context
+ * @return EntityMergerInterface|null
+ */
+ public function findMergerForObject(object $target, object $other, array $context = []): ?EntityMergerInterface
+ {
+ foreach ($this->mergers as $merger) {
+ if ($merger->supports($target, $other, $context)) {
+ return $merger;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * This function merges the other entity into the target entity. If no merger is found an exception is thrown.
+ * The target entity will be modified and returned.
+ * @param object $target
+ * @param object $other
+ * @param array $context
+ * @template T of object
+ * @phpstan-param T $target
+ * @phpstan-param T $other
+ * @phpstan-return T
+ * @return object
+ */
+ public function merge(object $target, object $other, array $context = []): object
+ {
+ $merger = $this->findMergerForObject($target, $other, $context);
+ if ($merger === null) {
+ throw new \RuntimeException('No merger found for merging '.$other::class.' into '.$target::class);
+ }
+ return $merger->merge($target, $other, $context);
+ }
+}
\ No newline at end of file
diff --git a/src/Services/EntityMergers/Mergers/EntityMergerHelperTrait.php b/src/Services/EntityMergers/Mergers/EntityMergerHelperTrait.php
new file mode 100644
index 00000000..64c952a9
--- /dev/null
+++ b/src/Services/EntityMergers/Mergers/EntityMergerHelperTrait.php
@@ -0,0 +1,358 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\EntityMergers\Mergers;
+
+use App\Entity\Attachments\Attachment;
+use App\Entity\Attachments\AttachmentContainingDBElement;
+use App\Entity\Base\AbstractNamedDBElement;
+use App\Entity\Base\AbstractStructuralDBElement;
+use App\Entity\Parameters\AbstractParameter;
+use App\Entity\Parts\Part;
+use Doctrine\Common\Collections\Collection;
+use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
+use Symfony\Contracts\Service\Attribute\Required;
+
+use function Symfony\Component\String\u;
+
+/**
+ * This trait provides helper methods for entity mergers.
+ * By default, it uses the value from the target entity, unless it not fullfills a condition.
+ */
+trait EntityMergerHelperTrait
+{
+ protected PropertyAccessorInterface $property_accessor;
+
+ #[Required]
+ public function setPropertyAccessor(PropertyAccessorInterface $property_accessor): void
+ {
+ $this->property_accessor = $property_accessor;
+ }
+
+ /**
+ * Choice the value to use from the target or the other entity by using a callback function.
+ *
+ * @param callable $callback The callback to use. The signature is: function($target_value, $other_value, $target, $other, $field). The callback should return the value to use.
+ * @param object $target The target entity
+ * @param object $other The other entity
+ * @param string $field The field to use
+ * @return object The target entity with the value set
+ */
+ protected function useCallback(callable $callback, object $target, object $other, string $field): object
+ {
+ //Get the values from the entities
+ $target_value = $this->property_accessor->getValue($target, $field);
+ $other_value = $this->property_accessor->getValue($other, $field);
+
+ //Call the callback, with the signature: function($target_value, $other_value, $target, $other, $field)
+ //The callback should return the value to use
+ $value = $callback($target_value, $other_value, $target, $other, $field);
+
+ //Set the value
+ $this->property_accessor->setValue($target, $field, $value);
+
+ return $target;
+ }
+
+ /**
+ * Use the value from the other entity, if the value from the target entity is null.
+ *
+ * @param object $target The target entity
+ * @param object $other The other entity
+ * @param string $field The field to use
+ * @return object The target entity with the value set
+ */
+ protected function useOtherValueIfNotNull(object $target, object $other, string $field): object
+ {
+ return $this->useCallback(
+ fn($target_value, $other_value) => $target_value ?? $other_value,
+ $target,
+ $other,
+ $field
+ );
+
+ }
+
+ /**
+ * Use the value from the other entity, if the value from the target entity is empty.
+ *
+ * @param object $target The target entity
+ * @param object $other The other entity
+ * @param string $field The field to use
+ * @return object The target entity with the value set
+ */
+ protected function useOtherValueIfNotEmtpy(object $target, object $other, string $field): object
+ {
+ return $this->useCallback(
+ fn($target_value, $other_value) => empty($target_value) ? $other_value : $target_value,
+ $target,
+ $other,
+ $field
+ );
+ }
+
+ /**
+ * Use the larger value from the target and the other entity for the given field.
+ *
+ * @param object $target
+ * @param object $other
+ * @param string $field
+ * @return object
+ */
+ protected function useLargerValue(object $target, object $other, string $field): object
+ {
+ return $this->useCallback(
+ fn($target_value, $other_value) => max($target_value, $other_value),
+ $target,
+ $other,
+ $field
+ );
+ }
+
+ /**
+ * Use the smaller value from the target and the other entity for the given field.
+ *
+ * @param object $target
+ * @param object $other
+ * @param string $field
+ * @return object
+ */
+ protected function useSmallerValue(object $target, object $other, string $field): object
+ {
+ return $this->useCallback(
+ fn($target_value, $other_value) => min($target_value, $other_value),
+ $target,
+ $other,
+ $field
+ );
+ }
+
+ /**
+ * Perform an OR operation on the boolean values from the target and the other entity for the given field.
+ * This effectively means that the value is true, if it is true in at least one of the entities.
+ * @param object $target
+ * @param object $other
+ * @param string $field
+ * @return object
+ */
+ protected function useTrueValue(object $target, object $other, string $field): object
+ {
+ return $this->useCallback(
+ fn(bool $target_value, bool $other_value): bool => $target_value || $other_value,
+ $target,
+ $other,
+ $field
+ );
+ }
+
+ /**
+ * Perform a merge of comma separated lists from the target and the other entity for the given field.
+ * The values are merged and duplicates are removed.
+ * @param object $target
+ * @param object $other
+ * @param string $field
+ * @return object
+ */
+ protected function mergeTags(object $target, object $other, string $field, string $separator = ','): object
+ {
+ return $this->useCallback(
+ function (string|null $t, string|null $o) use ($separator): string {
+ //Explode the strings into arrays
+ $t_array = explode($separator, $t ?? '');
+ $o_array = explode($separator, $o ?? '');
+
+ //Merge the arrays and remove duplicates
+ $tmp = array_unique(array_merge($t_array, $o_array));
+
+ //Implode the array back to a string
+ return implode($separator, $tmp);
+ },
+ $target,
+ $other,
+ $field
+ );
+ }
+
+ /**
+ * Merge the collections from the target and the other entity for the given field and put all items into the target collection.
+ * @param object $target
+ * @param object $other
+ * @param string $field
+ * @param callable|null $equal_fn A function, which checks if two items are equal. The signature is: function(object $target, object other): bool.
+ * Return true if the items are equal, false otherwise. If two items are equal, the item from the other collection is not added to the target collection.
+ * If null, the items are compared by (instance) identity.
+ * @return object
+ */
+ protected function mergeCollections(object $target, object $other, string $field, ?callable $equal_fn = null): object
+ {
+ $target_collection = $this->property_accessor->getValue($target, $field);
+ $other_collection = $this->property_accessor->getValue($other, $field);
+
+ if (!$target_collection instanceof Collection) {
+ throw new \InvalidArgumentException("The target field $field is not a collection");
+ }
+
+ //Clone the items from the other collection
+ $clones = [];
+ foreach ($other_collection as $item) {
+ //Check if the item is already in the target collection
+ if ($equal_fn !== null) {
+ foreach ($target_collection as $target_item) {
+ if ($equal_fn($target_item, $item)) {
+ continue 2;
+ }
+ }
+ } elseif ($target_collection->contains($item)) {
+ continue;
+ }
+
+ $clones[] = clone $item;
+ }
+
+ $tmp = array_merge($target_collection->toArray(), $clones);
+
+ //Create a new collection with the clones and merge it into the target collection
+ $this->property_accessor->setValue($target, $field, $tmp);
+
+ return $target;
+ }
+
+ /**
+ * Merge the attachments from the target and the other entity.
+ * @param AttachmentContainingDBElement $target
+ * @param AttachmentContainingDBElement $other
+ * @return object
+ */
+ protected function mergeAttachments(AttachmentContainingDBElement $target, AttachmentContainingDBElement $other): object
+ {
+ return $this->mergeCollections($target, $other, 'attachments', fn(Attachment $t, Attachment $o): bool => $t->getName() === $o->getName()
+ && $t->getAttachmentType() === $o->getAttachmentType()
+ && $t->getExternalPath() === $o->getExternalPath()
+ && $t->getInternalPath() === $o->getInternalPath());
+ }
+
+ /**
+ * Merge the parameters from the target and the other entity.
+ * @param AbstractStructuralDBElement|Part $target
+ * @param AbstractStructuralDBElement|Part $other
+ * @return object
+ */
+ protected function mergeParameters(AbstractStructuralDBElement|Part $target, AbstractStructuralDBElement|Part $other): object
+ {
+ return $this->mergeCollections($target, $other, 'parameters', fn(AbstractParameter $t, AbstractParameter $o): bool => $t->getName() === $o->getName()
+ && $t->getSymbol() === $o->getSymbol()
+ && $t->getUnit() === $o->getUnit()
+ && $t->getValueMax() === $o->getValueMax()
+ && $t->getValueMin() === $o->getValueMin()
+ && $t->getValueTypical() === $o->getValueTypical()
+ && $t->getValueText() === $o->getValueText()
+ && $t->getGroup() === $o->getGroup());
+ }
+
+ /**
+ * Check if the two strings have equal content.
+ * This method is case-insensitive and ignores whitespace.
+ * @param string|\Stringable $t
+ * @param string|\Stringable $o
+ * @return bool
+ */
+ protected function areStringsEqual(string|\Stringable $t, string|\Stringable $o): bool
+ {
+ $t_str = u($t)->trim()->folded();
+ $o_str = u($o)->trim()->folded();
+
+ return $t_str->equalsTo($o_str);
+ }
+
+ /**
+ * Merge the text from the target and the other entity for the given field by attaching the other text to the target text via the given separator.
+ * For example, if the target text is "Hello" and the other text is "World", the result is "Hello / World".
+ * If the text is the same in both entities, the target text is returned.
+ * @param object $target
+ * @param object $other
+ * @param string $field
+ * @param string $separator
+ * @return object
+ */
+ protected function mergeTextWithSeparator(object $target, object $other, string $field, string $separator = ' / '): object
+ {
+ return $this->useCallback(
+ function (string $t, string $o) use ($separator): string {
+ //Check if the strings are equal
+ if ($this->areStringsEqual($t, $o)) {
+ return $t;
+ }
+
+ //Skip empty strings
+ if (trim($t) === '') {
+ return trim($o);
+ }
+ if (trim($o) === '') {
+ return trim($t);
+ }
+
+ return trim($t) . $separator . trim($o);
+ },
+ $target,
+ $other,
+ $field
+ );
+ }
+
+ /**
+ * Merge two comments from the target and the other entity for the given field.
+ * The comments of the both entities get concated, while the second part get a headline with the name of the old part.
+ * @param AbstractNamedDBElement $target
+ * @param AbstractNamedDBElement $other
+ * @param string $field
+ * @return object
+ */
+ protected function mergeComment(AbstractNamedDBElement $target, AbstractNamedDBElement $other, string $field = 'comment'): object
+ {
+ return $this->useCallback(
+ function (string $t, string $o) use ($other): string {
+ //Check if the strings are equal
+ if ($this->areStringsEqual($t, $o)) {
+ return $t;
+ }
+
+ //Skip empty strings
+ if (trim($t) === '') {
+ return trim($o);
+ }
+ if (trim($o) === '') {
+ return trim($t);
+ }
+
+ return sprintf("%s\n\n%s:\n%s",
+ trim($t),
+ $other->getName(),
+ trim($o)
+ );
+ },
+ $target,
+ $other,
+ $field
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/Services/EntityMergers/Mergers/EntityMergerInterface.php b/src/Services/EntityMergers/Mergers/EntityMergerInterface.php
new file mode 100644
index 00000000..046fc0ea
--- /dev/null
+++ b/src/Services/EntityMergers/Mergers/EntityMergerInterface.php
@@ -0,0 +1,58 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\EntityMergers\Mergers;
+
+
+use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
+
+/**
+ * @template T of object
+ */
+#[AutoconfigureTag('app.entity_merger')]
+interface EntityMergerInterface
+{
+ /**
+ * Determines if this merger supports merging the other entity into the target entity.
+ * @param object $target
+ * @phpstan-param T $target
+ * @param object $other
+ * @phpstan-param T $other
+ * @param array $context
+ * @return bool True if this merger supports merging the other entity into the target entity, false otherwise
+ */
+ public function supports(object $target, object $other, array $context = []): bool;
+
+ /**
+ * Merge the other entity into the target entity.
+ * The target entity will be modified and returned.
+ * @param object $target
+ * @phpstan-param T $target
+ * @param object $other
+ * @phpstan-param T $other
+ * @param array $context
+ * @phpstan-return T
+ * @return object
+ */
+ public function merge(object $target, object $other, array $context = []): object;
+}
\ No newline at end of file
diff --git a/src/Services/EntityMergers/Mergers/PartMerger.php b/src/Services/EntityMergers/Mergers/PartMerger.php
new file mode 100644
index 00000000..4ce779e8
--- /dev/null
+++ b/src/Services/EntityMergers/Mergers/PartMerger.php
@@ -0,0 +1,186 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\EntityMergers\Mergers;
+
+use App\Entity\Parts\InfoProviderReference;
+use App\Entity\Parts\ManufacturingStatus;
+use App\Entity\Parts\Part;
+use App\Entity\Parts\PartAssociation;
+use App\Entity\PriceInformations\Orderdetail;
+
+/**
+ * This class merges two parts together.
+ *
+ * @implements EntityMergerInterface
+ * @see \App\Tests\Services\EntityMergers\Mergers\PartMergerTest
+ */
+class PartMerger implements EntityMergerInterface
+{
+
+ use EntityMergerHelperTrait;
+
+ public function supports(object $target, object $other, array $context = []): bool
+ {
+ return $target instanceof Part && $other instanceof Part;
+ }
+
+ public function merge(object $target, object $other, array $context = []): Part
+ {
+ if (!$target instanceof Part || !$other instanceof Part) {
+ throw new \InvalidArgumentException('The target and the other entity must be instances of Part');
+ }
+
+ //Merge basic fields
+ $this->mergeTextWithSeparator($target, $other, 'name');
+ $this->mergeTextWithSeparator($target, $other, 'description');
+ $this->mergeComment($target, $other);
+ $this->useOtherValueIfNotEmtpy($target, $other, 'manufacturer_product_url');
+ $this->useOtherValueIfNotEmtpy($target, $other, 'manufacturer_product_number');
+ $this->useOtherValueIfNotEmtpy($target, $other, 'mass');
+ $this->useOtherValueIfNotEmtpy($target, $other, 'ipn');
+
+ //Merge relations to other entities
+ $this->useOtherValueIfNotNull($target, $other, 'manufacturer');
+ $this->useOtherValueIfNotNull($target, $other, 'footprint');
+ $this->useOtherValueIfNotNull($target, $other, 'category');
+ $this->useOtherValueIfNotNull($target, $other, 'partUnit');
+
+ //We assume that the higher value is the correct one for minimum instock
+ $this->useLargerValue($target, $other, 'minamount');
+
+ //We assume that a part needs review and is a favorite if one of the parts is
+ $this->useTrueValue($target, $other, 'needs_review');
+ $this->useTrueValue($target, $other, 'favorite');
+
+ //Merge the tags using the tag merger
+ $this->mergeTags($target, $other, 'tags');
+
+ //Merge manufacturing status
+ $this->useCallback(function (?ManufacturingStatus $t, ?ManufacturingStatus $o): ManufacturingStatus {
+ //Use the other value, if the target value is not set
+ if ($t === ManufacturingStatus::NOT_SET || $t === null) {
+ return $o ?? ManufacturingStatus::NOT_SET;
+ }
+
+ return $t;
+ }, $target, $other, 'manufacturing_status');
+
+ //Merge provider reference
+ $this->useCallback(function (InfoProviderReference $t, InfoProviderReference $o): InfoProviderReference {
+ if (!$t->isProviderCreated() && $o->isProviderCreated()) {
+ return $o;
+ }
+ return $t;
+ }, $target, $other, 'providerReference');
+
+ //Merge the collections
+ $this->mergeCollectionFields($target, $other, $context);
+
+ return $target;
+ }
+
+ private function comparePartAssociations(PartAssociation $t, PartAssociation $o): bool {
+ //We compare the translation keys, as it contains info about the type and other type info
+ return $t->getOther() === $o->getOther()
+ && $t->getTypeTranslationKey() === $o->getTypeTranslationKey();
+ }
+
+ private function mergeCollectionFields(Part $target, Part $other, array $context): void
+ {
+ /********************************************************************************
+ * Merge collections
+ ********************************************************************************/
+
+ //Lots from different parts are never considered equal, so we just merge them together
+ $this->mergeCollections($target, $other, 'partLots');
+ $this->mergeAttachments($target, $other);
+ $this->mergeParameters($target, $other);
+
+ //Merge the associations
+ $this->mergeCollections($target, $other, 'associated_parts_as_owner', $this->comparePartAssociations(...));
+
+ //We have to recreate the associations towards the other part, as they are not created by the merger
+ foreach ($other->getAssociatedPartsAsOther() as $association) {
+ //Clone the association
+ $clone = clone $association;
+ //Set the target part as the other part
+ $clone->setOther($target);
+ $owner = $clone->getOwner();
+ if (!$owner) {
+ continue;
+ }
+ //Ensure that the association is not already present
+ foreach ($owner->getAssociatedPartsAsOwner() as $existing_association) {
+ if ($this->comparePartAssociations($existing_association, $clone)) {
+ continue 2;
+ }
+ }
+
+ //Add the association to the owner
+ $owner->addAssociatedPartsAsOwner($clone);
+ }
+
+ $this->mergeCollections($target, $other, 'orderdetails', function (Orderdetail $t, Orderdetail $o) {
+ //First check that the orderdetails infos are equal
+ $tmp = $t->getSupplier() === $o->getSupplier()
+ && $t->getSupplierPartNr() === $o->getSupplierPartNr()
+ && $t->getSupplierProductUrl(false) === $o->getSupplierProductUrl(false);
+
+ if (!$tmp) {
+ return false;
+ }
+
+ //Check if the pricedetails are equal
+ $t_pricedetails = $t->getPricedetails();
+ $o_pricedetails = $o->getPricedetails();
+ //Ensure that both pricedetails have the same length
+ if (count($t_pricedetails) !== count($o_pricedetails)) {
+ return false;
+ }
+
+ //Check if all pricedetails are equal
+ for ($n=0, $nMax = count($t_pricedetails); $n< $nMax; $n++) {
+ $t_price = $t_pricedetails->get($n);
+ $o_price = $o_pricedetails->get($n);
+
+ if (!$t_price->getPrice()->isEqualTo($o_price->getPrice())
+ || $t_price->getCurrency() !== $o_price->getCurrency()
+ || $t_price->getPriceRelatedQuantity() !== $o_price->getPriceRelatedQuantity()
+ || $t_price->getMinDiscountQuantity() !== $o_price->getMinDiscountQuantity()
+ ) {
+ return false;
+ }
+ }
+
+ //If all pricedetails are equal, the orderdetails are equal
+ return true;
+ });
+ //The pricedetails are not correctly assigned to the new orderdetails, so fix that
+ foreach ($target->getOrderdetails() as $orderdetail) {
+ foreach ($orderdetail->getPricedetails() as $pricedetail) {
+ $pricedetail->setOrderdetail($orderdetail);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Services/EntityURLGenerator.php b/src/Services/EntityURLGenerator.php
index 1ed659e4..78db06f0 100644
--- a/src/Services/EntityURLGenerator.php
+++ b/src/Services/EntityURLGenerator.php
@@ -26,6 +26,7 @@ use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\PartAttachment;
use App\Entity\Base\AbstractDBElement;
+use App\Entity\Parameters\PartParameter;
use App\Entity\ProjectSystem\Project;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parts\Category;
@@ -34,7 +35,7 @@ use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
-use App\Entity\Parts\Storelocation;
+use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\PriceInformations\Orderdetail;
@@ -43,9 +44,7 @@ use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use App\Exceptions\EntityNotSupportedException;
use App\Services\Attachments\AttachmentURLGenerator;
-use DateTime;
use function array_key_exists;
-use function get_class;
use InvalidArgumentException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
@@ -56,13 +55,8 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
*/
class EntityURLGenerator
{
- protected UrlGeneratorInterface $urlGenerator;
- protected AttachmentURLGenerator $attachmentURLGenerator;
-
- public function __construct(UrlGeneratorInterface $urlGenerator, AttachmentURLGenerator $attachmentURLGenerator)
+ public function __construct(protected UrlGeneratorInterface $urlGenerator, protected AttachmentURLGenerator $attachmentURLGenerator)
{
- $this->urlGenerator = $urlGenerator;
- $this->attachmentURLGenerator = $attachmentURLGenerator;
}
/**
@@ -78,35 +72,25 @@ class EntityURLGenerator
* @throws EntityNotSupportedException thrown if the entity is not supported for the given type
* @throws InvalidArgumentException thrown if the givent type is not existing
*/
- public function getURL($entity, string $type): string
+ public function getURL(mixed $entity, string $type): string
{
- switch ($type) {
- case 'info':
- return $this->infoURL($entity);
- case 'edit':
- return $this->editURL($entity);
- case 'create':
- return $this->createURL($entity);
- case 'clone':
- return $this->cloneURL($entity);
- case 'list':
- case 'list_parts':
- return $this->listPartsURL($entity);
- case 'delete':
- return $this->deleteURL($entity);
- case 'file_download':
- return $this->downloadURL($entity);
- case 'file_view':
- return $this->viewURL($entity);
- }
-
- throw new InvalidArgumentException('Method is not supported!');
+ return match ($type) {
+ 'info' => $this->infoURL($entity),
+ 'edit' => $this->editURL($entity),
+ 'create' => $this->createURL($entity),
+ 'clone' => $this->cloneURL($entity),
+ 'list', 'list_parts' => $this->listPartsURL($entity),
+ 'delete' => $this->deleteURL($entity),
+ 'file_download' => $this->downloadURL($entity),
+ 'file_view' => $this->viewURL($entity),
+ default => throw new InvalidArgumentException('Method is not supported!'),
+ };
}
/**
* Gets the URL to view the given element at a given timestamp.
*/
- public function timeTravelURL(AbstractDBElement $entity, DateTime $dateTime): string
+ public function timeTravelURL(AbstractDBElement $entity, \DateTimeInterface $dateTime): string
{
$map = [
Part::class => 'part_info',
@@ -116,7 +100,7 @@ class EntityURLGenerator
Project::class => 'project_edit',
Supplier::class => 'supplier_edit',
Manufacturer::class => 'manufacturer_edit',
- Storelocation::class => 'store_location_edit',
+ StorageLocation::class => 'store_location_edit',
Footprint::class => 'footprint_edit',
User::class => 'user_edit',
Currency::class => 'currency_edit',
@@ -133,7 +117,7 @@ class EntityURLGenerator
'timestamp' => $dateTime->getTimestamp(),
]
);
- } catch (EntityNotSupportedException $exception) {
+ } catch (EntityNotSupportedException) {
if ($entity instanceof PartLot) {
return $this->urlGenerator->generate('part_info', [
'id' => $entity->getPart()->getID(),
@@ -158,33 +142,48 @@ class EntityURLGenerator
'timestamp' => $dateTime->getTimestamp(),
]);
}
- }
-
- //Otherwise throw an error
- throw new EntityNotSupportedException('The given entity is not supported yet!');
- }
-
- public function viewURL(Attachment $entity): ?string
- {
- if ($entity->isExternal()) { //For external attachments, return the link to external path
- return $entity->getURL();
- }
- //return $this->urlGenerator->generate('attachment_view', ['id' => $entity->getID()]);
- return $this->attachmentURLGenerator->getViewURL($entity);
- }
-
- public function downloadURL($entity): ?string
- {
- if ($entity instanceof Attachment) {
- if ($entity->isExternal()) { //For external attachments, return the link to external path
- return $entity->getURL();
+ if ($entity instanceof PartParameter) {
+ return $this->urlGenerator->generate('part_info', [
+ 'id' => $entity->getElement()->getID(),
+ 'timestamp' => $dateTime->getTimestamp(),
+ ]);
}
-
- return $this->attachmentURLGenerator->getDownloadURL($entity);
}
//Otherwise throw an error
- throw new EntityNotSupportedException(sprintf('The given entity is not supported yet! Passed class type: %s', get_class($entity)));
+ throw new EntityNotSupportedException('The given entity is not supported yet! Passed class type: '.$entity::class);
+ }
+
+ public function viewURL(Attachment $entity): string
+ {
+ //If the underlying file path is invalid, null gets returned, which is not allowed here.
+ //We still have the chance to use an external path, if it is set.
+ if ($entity->hasInternal() && ($url = $this->attachmentURLGenerator->getInternalViewURL($entity)) !== null) {
+ return $url;
+ }
+
+ if($entity->hasExternal()) {
+ return $entity->getExternalPath();
+ }
+
+ throw new \RuntimeException('Attachment has no internal nor external path!');
+ }
+
+ public function downloadURL($entity): string
+ {
+ if (!($entity instanceof Attachment)) {
+ throw new EntityNotSupportedException(sprintf('The given entity is not supported yet! Passed class type: %s', $entity::class));
+ }
+
+ if ($entity->hasInternal()) {
+ return $this->attachmentURLGenerator->getInternalDownloadURL($entity);
+ }
+
+ if($entity->hasExternal()) {
+ return $entity->getExternalPath();
+ }
+
+ throw new \RuntimeException('Attachment has not internal or external path!');
}
/**
@@ -207,7 +206,7 @@ class EntityURLGenerator
Project::class => 'project_info',
Supplier::class => 'supplier_edit',
Manufacturer::class => 'manufacturer_edit',
- Storelocation::class => 'store_location_edit',
+ StorageLocation::class => 'store_location_edit',
Footprint::class => 'footprint_edit',
User::class => 'user_edit',
Currency::class => 'currency_edit',
@@ -222,13 +221,13 @@ class EntityURLGenerator
/**
* Generates an URL to a page, where this entity can be edited.
*
- * @param mixed $entity The entity for which the edit link should be generated
+ * @param AbstractDBElement $entity The entity for which the edit link should be generated
*
* @return string the URL to the edit page
*
* @throws EntityNotSupportedException If the method is not supported for the given Entity
*/
- public function editURL($entity): string
+ public function editURL(AbstractDBElement $entity): string
{
$map = [
Part::class => 'part_edit',
@@ -237,7 +236,7 @@ class EntityURLGenerator
Project::class => 'project_edit',
Supplier::class => 'supplier_edit',
Manufacturer::class => 'manufacturer_edit',
- Storelocation::class => 'store_location_edit',
+ StorageLocation::class => 'store_location_edit',
Footprint::class => 'footprint_edit',
User::class => 'user_edit',
Currency::class => 'currency_edit',
@@ -252,13 +251,14 @@ class EntityURLGenerator
/**
* Generates an URL to a page, where a entity of this type can be created.
*
- * @param mixed $entity The entity for which the link should be generated
+ * @param AbstractDBElement|string $entity The entity (or the entity class) for which the link should be generated
+ * @phpstan-param AbstractDBElement|class-string $entity
*
* @return string the URL to the page
*
* @throws EntityNotSupportedException If the method is not supported for the given Entity
*/
- public function createURL($entity): string
+ public function createURL(AbstractDBElement|string $entity): string
{
$map = [
Part::class => 'part_new',
@@ -267,7 +267,7 @@ class EntityURLGenerator
Project::class => 'project_new',
Supplier::class => 'supplier_new',
Manufacturer::class => 'manufacturer_new',
- Storelocation::class => 'store_location_new',
+ StorageLocation::class => 'store_location_new',
Footprint::class => 'footprint_new',
User::class => 'user_new',
Currency::class => 'currency_new',
@@ -298,7 +298,7 @@ class EntityURLGenerator
Project::class => 'device_clone',
Supplier::class => 'supplier_clone',
Manufacturer::class => 'manufacturer_clone',
- Storelocation::class => 'store_location_clone',
+ StorageLocation::class => 'store_location_clone',
Footprint::class => 'footprint_clone',
User::class => 'user_clone',
Currency::class => 'currency_clone',
@@ -328,7 +328,7 @@ class EntityURLGenerator
Footprint::class => 'part_list_footprint',
Manufacturer::class => 'part_list_manufacturer',
Supplier::class => 'part_list_supplier',
- Storelocation::class => 'part_list_store_location',
+ StorageLocation::class => 'part_list_store_location',
];
return $this->urlGenerator->generate($this->mapToController($map, $entity), ['id' => $entity->getID()]);
@@ -343,7 +343,7 @@ class EntityURLGenerator
Project::class => 'project_delete',
Supplier::class => 'supplier_delete',
Manufacturer::class => 'manufacturer_delete',
- Storelocation::class => 'store_location_delete',
+ StorageLocation::class => 'store_location_delete',
Footprint::class => 'footprint_delete',
User::class => 'user_delete',
Currency::class => 'currency_delete',
@@ -360,26 +360,27 @@ class EntityURLGenerator
* Throws an exception if the entity class is not known to the map.
*
* @param array $map The map that should be used for determing the controller
- * @param mixed $entity The entity for which the controller name should be determined
+ * @param AbstractDBElement|string $entity The entity for which the controller name should be determined
+ * @phpstan-param AbstractDBElement|class-string $entity
*
* @return string The name of the controller fitting the entity class
*
* @throws EntityNotSupportedException
*/
- protected function mapToController(array $map, $entity): string
+ protected function mapToController(array $map, string|AbstractDBElement $entity): string
{
- $class = get_class($entity);
+ $class = is_string($entity) ? $entity : $entity::class;
//Check if we have an direct mapping for the given class
if (!array_key_exists($class, $map)) {
//Check if we need to check inheritance by looping through our map
foreach (array_keys($map) as $key) {
- if (is_a($entity, $key)) {
+ if (is_a($entity, $key, true)) {
return $map[$key];
}
}
- throw new EntityNotSupportedException(sprintf('The given entity is not supported yet! Passed class type: %s', get_class($entity)));
+ throw new EntityNotSupportedException(sprintf('The given entity is not supported yet! Passed class type: %s', $entity::class));
}
return $map[$class];
diff --git a/src/Services/Formatters/AmountFormatter.php b/src/Services/Formatters/AmountFormatter.php
index 18d52a44..73d59113 100644
--- a/src/Services/Formatters/AmountFormatter.php
+++ b/src/Services/Formatters/AmountFormatter.php
@@ -23,35 +23,30 @@ declare(strict_types=1);
namespace App\Services\Formatters;
use App\Entity\Parts\MeasurementUnit;
-use App\Services\Formatters\SIFormatter;
use InvalidArgumentException;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
- * This service formats an part amout using a Measurement Unit.
+ * This service formats a part amount using a Measurement Unit.
+ * @see \App\Tests\Services\Formatters\AmountFormatterTest
*/
class AmountFormatter
{
- protected SIFormatter $siFormatter;
-
- public function __construct(SIFormatter $siFormatter)
+ public function __construct(protected SIFormatter $siFormatter)
{
- $this->siFormatter = $siFormatter;
}
/**
* Formats the given value using the measurement unit and options.
*
- * @param float|string|int $value
* @param MeasurementUnit|null $unit The measurement unit, whose unit symbol should be used for formatting.
* If set to null, it is assumed that the part amount is measured in pieces.
*
* @return string The formatted string
- *
* @throws InvalidArgumentException thrown if $value is not numeric
*/
- public function format($value, ?MeasurementUnit $unit = null, array $options = []): string
+ public function format(float|string|int $value, ?MeasurementUnit $unit = null, array $options = []): string
{
if (!is_numeric($value)) {
throw new InvalidArgumentException('$value must be an numeric value!');
@@ -77,7 +72,7 @@ class AmountFormatter
//Otherwise just output it
if (!empty($options['unit'])) {
$format_string = '%.'.$options['decimals'].'f '.$options['unit'];
- } else { //Dont add space after number if no unit was specified
+ } else { //Don't add space after number if no unit was specified
$format_string = '%.'.$options['decimals'].'f';
}
@@ -127,7 +122,7 @@ class AmountFormatter
$resolver->setAllowedTypes('decimals', 'int');
$resolver->setNormalizer('decimals', static function (Options $options, $value) {
- // If the unit is integer based, then dont show any decimals
+ // If the unit is integer based, then don't show any decimals
if ($options['is_integer']) {
return 0;
}
diff --git a/src/Services/Formatters/MarkdownParser.php b/src/Services/Formatters/MarkdownParser.php
index 805fd4bf..f3ef07df 100644
--- a/src/Services/Formatters/MarkdownParser.php
+++ b/src/Services/Formatters/MarkdownParser.php
@@ -25,22 +25,19 @@ namespace App\Services\Formatters;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
- * This class allows you to convert markdown text to HTML.
+ * This class allows you to convert Markdown text to HTML.
*/
class MarkdownParser
{
- protected TranslatorInterface $translator;
-
- public function __construct(TranslatorInterface $translator)
+ public function __construct(protected TranslatorInterface $translator)
{
- $this->translator = $translator;
}
/**
* Mark the markdown for rendering.
* The rendering of markdown is done on client side.
*
- * @param string $markdown the markdown text that should be parsed to html
+ * @param string $markdown the Markdown text that should be parsed to html
* @param bool $inline_mode When true, p blocks will have no margins behind them
*
* @return string the markdown in a version that can be parsed on client side
diff --git a/src/Services/Formatters/MoneyFormatter.php b/src/Services/Formatters/MoneyFormatter.php
index ee8a189a..44a49cb5 100644
--- a/src/Services/Formatters/MoneyFormatter.php
+++ b/src/Services/Formatters/MoneyFormatter.php
@@ -28,27 +28,25 @@ use NumberFormatter;
class MoneyFormatter
{
- protected string $base_currency;
protected string $locale;
- public function __construct(string $base_currency)
+ public function __construct(protected string $base_currency)
{
- $this->base_currency = $base_currency;
$this->locale = Locale::getDefault();
}
/**
- * Format the the given value in the given currency.
+ * Format the given value in the given currency.
*
* @param string|float $value the value that should be formatted
* @param Currency|null $currency The currency that should be used for formatting. If null the global one is used
* @param int $decimals the number of decimals that should be shown
* @param bool $show_all_digits if set to true, all digits are shown, even if they are null
*/
- public function format($value, ?Currency $currency = null, int $decimals = 5, bool $show_all_digits = false): string
+ public function format(string|float $value, ?Currency $currency = null, int $decimals = 5, bool $show_all_digits = false): string
{
$iso_code = $this->base_currency;
- if (null !== $currency && !empty($currency->getIsoCode())) {
+ if ($currency instanceof Currency && ($currency->getIsoCode() !== '')) {
$iso_code = $currency->getIsoCode();
}
diff --git a/src/Services/Formatters/SIFormatter.php b/src/Services/Formatters/SIFormatter.php
index 288641a3..a6325987 100644
--- a/src/Services/Formatters/SIFormatter.php
+++ b/src/Services/Formatters/SIFormatter.php
@@ -24,6 +24,7 @@ namespace App\Services\Formatters;
/**
* A service that helps you to format values using the SI prefixes.
+ * @see \App\Tests\Services\Formatters\SIFormatterTest
*/
class SIFormatter
{
@@ -45,7 +46,7 @@ class SIFormatter
*
* @param int $magnitude the magnitude for which the prefix should be determined
*
- * @return array A array, containing the divisor in first element, and the prefix symbol in second. For example, [1000, "k"].
+ * @return array An array, containing the divisor in first element, and the prefix symbol in second. For example, [1000, "k"].
*/
public function getPrefixByMagnitude(int $magnitude): array
{
@@ -93,11 +94,7 @@ class SIFormatter
[$divisor, $symbol] = $this->getPrefixByMagnitude($this->getMagnitude($value));
$value /= $divisor;
//Build the format string, e.g.: %.2d km
- if ('' !== $unit || '' !== $symbol) {
- $format_string = '%.'.$decimals.'f '.$symbol.$unit;
- } else {
- $format_string = '%.'.$decimals.'f';
- }
+ $format_string = '' !== $unit || '' !== $symbol ? '%.'.$decimals.'f '.$symbol.$unit : '%.'.$decimals.'f';
return sprintf($format_string, $value);
}
diff --git a/src/Services/ImportExportSystem/BOMImporter.php b/src/Services/ImportExportSystem/BOMImporter.php
new file mode 100644
index 00000000..d4876445
--- /dev/null
+++ b/src/Services/ImportExportSystem/BOMImporter.php
@@ -0,0 +1,163 @@
+.
+ */
+namespace App\Services\ImportExportSystem;
+
+use App\Entity\ProjectSystem\Project;
+use App\Entity\ProjectSystem\ProjectBOMEntry;
+use InvalidArgumentException;
+use League\Csv\Reader;
+use Symfony\Component\HttpFoundation\File\File;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+/**
+ * @see \App\Tests\Services\ImportExportSystem\BOMImporterTest
+ */
+class BOMImporter
+{
+
+ private const MAP_KICAD_PCB_FIELDS = [
+ 0 => 'Id',
+ 1 => 'Designator',
+ 2 => 'Package',
+ 3 => 'Quantity',
+ 4 => 'Designation',
+ 5 => 'Supplier and ref',
+ ];
+
+ public function __construct()
+ {
+ }
+
+ protected function configureOptions(OptionsResolver $resolver): OptionsResolver
+ {
+ $resolver->setRequired('type');
+ $resolver->setAllowedValues('type', ['kicad_pcbnew']);
+
+ return $resolver;
+ }
+
+ /**
+ * Converts the given file into an array of BOM entries using the given options and save them into the given project.
+ * The changes are not saved into the database yet.
+ * @return ProjectBOMEntry[]
+ */
+ public function importFileIntoProject(File $file, Project $project, array $options): array
+ {
+ $bom_entries = $this->fileToBOMEntries($file, $options);
+
+ //Assign the bom_entries to the project
+ foreach ($bom_entries as $bom_entry) {
+ $project->addBomEntry($bom_entry);
+ }
+
+ return $bom_entries;
+ }
+
+ /**
+ * Converts the given file into an array of BOM entries using the given options.
+ * @return ProjectBOMEntry[]
+ */
+ public function fileToBOMEntries(File $file, array $options): array
+ {
+ return $this->stringToBOMEntries($file->getContent(), $options);
+ }
+
+ /**
+ * Import string data into an array of BOM entries, which are not yet assigned to a project.
+ * @param string $data The data to import
+ * @param array $options An array of options
+ * @return ProjectBOMEntry[] An array of imported entries
+ */
+ public function stringToBOMEntries(string $data, array $options): array
+ {
+ $resolver = new OptionsResolver();
+ $resolver = $this->configureOptions($resolver);
+ $options = $resolver->resolve($options);
+
+ return match ($options['type']) {
+ 'kicad_pcbnew' => $this->parseKiCADPCB($data, $options),
+ default => throw new InvalidArgumentException('Invalid import type!'),
+ };
+ }
+
+ private function parseKiCADPCB(string $data, array $options = []): array
+ {
+ $csv = Reader::createFromString($data);
+ $csv->setDelimiter(';');
+ $csv->setHeaderOffset(0);
+
+ $bom_entries = [];
+
+ foreach ($csv->getRecords() as $offset => $entry) {
+ //Translate the german field names to english
+ $entry = $this->normalizeColumnNames($entry);
+
+ //Ensure that the entry has all required fields
+ if (!isset ($entry['Designator'])) {
+ throw new \UnexpectedValueException('Designator missing at line '.($offset + 1).'!');
+ }
+ if (!isset ($entry['Package'])) {
+ throw new \UnexpectedValueException('Package missing at line '.($offset + 1).'!');
+ }
+ if (!isset ($entry['Designation'])) {
+ throw new \UnexpectedValueException('Designation missing at line '.($offset + 1).'!');
+ }
+ if (!isset ($entry['Quantity'])) {
+ throw new \UnexpectedValueException('Quantity missing at line '.($offset + 1).'!');
+ }
+
+ $bom_entry = new ProjectBOMEntry();
+ $bom_entry->setName($entry['Designation'] . ' (' . $entry['Package'] . ')');
+ $bom_entry->setMountnames($entry['Designator'] ?? '');
+ $bom_entry->setComment($entry['Supplier and ref'] ?? '');
+ $bom_entry->setQuantity((float) ($entry['Quantity'] ?? 1));
+
+ $bom_entries[] = $bom_entry;
+ }
+
+ return $bom_entries;
+ }
+
+ /**
+ * This function uses the order of the fields in the CSV files to make them locale independent.
+ * @param array $entry
+ * @return array
+ */
+ private function normalizeColumnNames(array $entry): array
+ {
+ $out = [];
+
+ //Map the entry order to the correct column names
+ foreach (array_values($entry) as $index => $field) {
+ if ($index > 5) {
+ break;
+ }
+
+ //@phpstan-ignore-next-line We want to keep this check just to be safe when something changes
+ $new_index = self::MAP_KICAD_PCB_FIELDS[$index] ?? throw new \UnexpectedValueException('Invalid field index!');
+ $out[$new_index] = $field;
+ }
+
+ return $out;
+ }
+}
diff --git a/src/Services/ImportExportSystem/EntityExporter.php b/src/Services/ImportExportSystem/EntityExporter.php
index 2d85097c..c37db50c 100644
--- a/src/Services/ImportExportSystem/EntityExporter.php
+++ b/src/Services/ImportExportSystem/EntityExporter.php
@@ -23,8 +23,13 @@ declare(strict_types=1);
namespace App\Services\ImportExportSystem;
use App\Entity\Base\AbstractNamedDBElement;
-use function in_array;
+use App\Entity\Base\AbstractStructuralDBElement;
+use App\Helpers\FilenameSanatizer;
+use App\Serializer\APIPlatform\SkippableItemNormalizer;
+use Symfony\Component\OptionsResolver\OptionsResolver;
use InvalidArgumentException;
+use Symfony\Component\Serializer\Exception\CircularReferenceException;
+use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use function is_array;
use ReflectionClass;
use ReflectionException;
@@ -32,114 +37,165 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Serializer\SerializerInterface;
+use function Symfony\Component\String\u;
/**
* Use this class to export an entity to multiple file formats.
+ * @see \App\Tests\Services\ImportExportSystem\EntityExporterTest
*/
class EntityExporter
{
- protected SerializerInterface $serializer;
-
- public function __construct(SerializerInterface $serializer)
+ public function __construct(protected SerializerInterface $serializer)
{
- /*$encoders = [new XmlEncoder(), new JsonEncoder(), new CSVEncoder(), new YamlEncoder()];
- $normalizers = [new ObjectNormalizer(), new DateTimeNormalizer()];
- $this->serializer = new Serializer($normalizers, $encoders);
- $this->serializer-> */
- $this->serializer = $serializer;
+ }
+
+ protected function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefault('format', 'csv');
+ $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']);
+
+ $resolver->setDefault('csv_delimiter', ';');
+ $resolver->setAllowedTypes('csv_delimiter', 'string');
+
+ $resolver->setDefault('level', 'extended');
+ $resolver->setAllowedValues('level', ['simple', 'extended', 'full']);
+
+ $resolver->setDefault('include_children', false);
+ $resolver->setAllowedTypes('include_children', 'bool');
}
/**
- * Exports an Entity or an array of entities to multiple file formats.
+ * Export the given entities using the given options.
+ * @param AbstractNamedDBElement|AbstractNamedDBElement[] $entities The data to export
+ * @param array $options The options to use for exporting
+ * @return string The serialized data
+ */
+ public function exportEntities(AbstractNamedDBElement|array $entities, array $options): string
+ {
+ if (!is_array($entities)) {
+ $entities = [$entities];
+ }
+
+ //Ensure that all entities are of type AbstractNamedDBElement
+ foreach ($entities as $entity) {
+ if (!$entity instanceof AbstractNamedDBElement) {
+ throw new InvalidArgumentException('All entities must be of type AbstractNamedDBElement!');
+ }
+ }
+
+ $resolver = new OptionsResolver();
+ $this->configureOptions($resolver);
+
+ $options = $resolver->resolve($options);
+
+ //If include children is set, then we need to add the include_children group
+ $groups = [$options['level']];
+ if ($options['include_children']) {
+ $groups[] = 'include_children';
+ }
+
+ return $this->serializer->serialize($entities, $options['format'],
+ [
+ 'groups' => $groups,
+ 'as_collection' => true,
+ 'csv_delimiter' => $options['csv_delimiter'],
+ 'xml_root_node_name' => 'PartDBExport',
+ 'partdb_export' => true,
+ //Skip the item normalizer, so that we dont get IRIs in the output
+ SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true,
+ //Handle circular references
+ AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => $this->handleCircularReference(...),
+ ]
+ );
+ }
+
+ private function handleCircularReference(object $object, string $format, array $context): string
+ {
+ if ($object instanceof AbstractStructuralDBElement) {
+ return $object->getFullPath("->");
+ } elseif ($object instanceof AbstractNamedDBElement) {
+ return $object->getName();
+ } elseif ($object instanceof \Stringable) {
+ return $object->__toString();
+ }
+
+ throw new CircularReferenceException('Circular reference detected for object of type '.get_class($object));
+ }
+
+ /**
+ * Exports an Entity or an array of entities to multiple file formats.
*
* @param Request $request the request that should be used for option resolving
- * @param AbstractNamedDBElement|object[] $entity
+ * @param AbstractNamedDBElement|object[] $entities
*
* @return Response the generated response containing the exported data
*
* @throws ReflectionException
*/
- public function exportEntityFromRequest($entity, Request $request): Response
+ public function exportEntityFromRequest(AbstractNamedDBElement|array $entities, Request $request): Response
{
- $format = $request->get('format') ?? 'json';
+ $options = [
+ 'format' => $request->get('format') ?? 'json',
+ 'level' => $request->get('level') ?? 'extended',
+ 'include_children' => $request->request->getBoolean('include_children') ?? false,
+ ];
- //Check if we have one of the supported formats
- if (!in_array($format, ['json', 'csv', 'yaml', 'xml'], true)) {
- throw new InvalidArgumentException('Given format is not supported!');
+ if (!is_array($entities)) {
+ $entities = [$entities];
}
- //Check export verbosity level
- $level = $request->get('level') ?? 'extended';
- if (!in_array($level, ['simple', 'extended', 'full'], true)) {
- throw new InvalidArgumentException('Given level is not supported!');
- }
+ //Do the serialization with the given options
+ $serialized_data = $this->exportEntities($entities, $options);
- //Check for include children option
- $include_children = $request->get('include_children') ?? false;
+ $response = new Response($serialized_data);
- //Check which groups we need to export, based on level and include_children
- $groups = [$level];
- if ($include_children) {
- $groups[] = 'include_children';
- }
+ //Resolve the format
+ $optionsResolver = new OptionsResolver();
+ $this->configureOptions($optionsResolver);
+ $options = $optionsResolver->resolve($options);
+
+ //Determine the content type for the response
//Plain text should work for all types
$content_type = 'text/plain';
//Try to use better content types based on the format
+ $format = $options['format'];
switch ($format) {
case 'xml':
$content_type = 'application/xml';
-
break;
case 'json':
$content_type = 'application/json';
-
break;
}
-
- //Ensure that we always serialize an array. This makes it easier to import the data again.
- if (is_array($entity)) {
- $entity_array = $entity;
- } else {
- $entity_array = [$entity];
- }
-
- $serialized_data = $this->serializer->serialize($entity_array, $format,
- [
- 'groups' => $groups,
- 'as_collection' => true,
- 'csv_delimiter' => ';', //Better for Excel
- 'xml_root_node_name' => 'PartDBExport',
- ]);
-
- $response = new Response($serialized_data);
-
$response->headers->set('Content-Type', $content_type);
//If view option is not specified, then download the file.
if (!$request->get('view')) {
- if ($entity instanceof AbstractNamedDBElement) {
- $entity_name = $entity->getName();
- } elseif (is_array($entity)) {
- if (empty($entity)) {
- throw new InvalidArgumentException('$entity must not be empty!');
- }
- //Use the class name of the first element for the filename
- $reflection = new ReflectionClass($entity[0]);
- $entity_name = $reflection->getShortName();
+ //Determine the filename
+ //When we only have one entity, then we can use the name of the entity
+ if (count($entities) === 1) {
+ $entity_name = $entities[0]->getName();
} else {
- throw new InvalidArgumentException('$entity type is not supported!');
+ //Use the class name of the first element for the filename otherwise
+ $reflection = new ReflectionClass($entities[0]);
+ $entity_name = $reflection->getShortName();
}
+ $level = $options['level'];
+
$filename = 'export_'.$entity_name.'_'.$level.'.'.$format;
+ //Sanitize the filename
+ $filename = FilenameSanatizer::sanitizeFilename($filename);
+
// Create the disposition of the file
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$filename,
- $string = preg_replace('![^'.preg_quote('-', '!').'a-z0-_9\s]+!', '', strtolower($filename))
+ u($filename)->ascii()->toString(),
);
// Set the content disposition
$response->headers->set('Content-Disposition', $disposition);
diff --git a/src/Services/ImportExportSystem/EntityImporter.php b/src/Services/ImportExportSystem/EntityImporter.php
index d6e00570..cecab12d 100644
--- a/src/Services/ImportExportSystem/EntityImporter.php
+++ b/src/Services/ImportExportSystem/EntityImporter.php
@@ -24,6 +24,12 @@ namespace App\Services\ImportExportSystem;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
+use App\Entity\Parts\Category;
+use App\Entity\Parts\Part;
+use App\Repository\StructuralDBElementRepository;
+use App\Serializer\APIPlatform\SkippableItemNormalizer;
+use Symfony\Component\Validator\ConstraintViolationList;
+use Symfony\Component\Validator\ConstraintViolationListInterface;
use function count;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
@@ -33,42 +39,58 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
+/**
+ * @see \App\Tests\Services\ImportExportSystem\EntityImporterTest
+ */
class EntityImporter
{
- protected SerializerInterface $serializer;
- protected EntityManagerInterface $em;
- protected ValidatorInterface $validator;
- public function __construct(SerializerInterface $serializer, EntityManagerInterface $em, ValidatorInterface $validator)
+ /**
+ * The encodings that are supported by the importer, and that should be autodeceted.
+ */
+ private const ENCODINGS = ["ASCII", "UTF-8", "ISO-8859-1", "ISO-8859-15", "Windows-1252", "UTF-16", "UTF-32"];
+
+ public function __construct(protected SerializerInterface $serializer, protected EntityManagerInterface $em, protected ValidatorInterface $validator)
{
- $this->serializer = $serializer;
- $this->em = $em;
- $this->validator = $validator;
}
/**
* Creates many entries at once, based on a (text) list of name.
- * The created enties are not persisted to database yet, so you have to do it yourself.
+ * The created entities are not persisted to database yet, so you have to do it yourself.
*
+ * @template T of AbstractNamedDBElement
* @param string $lines The list of names seperated by \n
* @param string $class_name The name of the class for which the entities should be created
+ * @phpstan-param class-string $class_name
* @param AbstractStructuralDBElement|null $parent the element which will be used as parent element for new elements
* @param array $errors an associative array containing all validation errors
+ * @param-out list $errors
*
- * @return AbstractStructuralDBElement[] An array containing all valid imported entities (with the type $class_name)
+ * @return AbstractNamedDBElement[] An array containing all valid imported entities (with the type $class_name)
+ * @return T[]
*/
public function massCreation(string $lines, string $class_name, ?AbstractStructuralDBElement $parent = null, array &$errors = []): array
{
+ //Try to detect the text encoding of the data and convert it to UTF-8
+ $lines = mb_convert_encoding($lines, 'UTF-8', mb_detect_encoding($lines, self::ENCODINGS));
+
//Expand every line to a single entry:
$names = explode("\n", $lines);
if (!is_a($class_name, AbstractNamedDBElement::class, true)) {
throw new InvalidArgumentException('$class_name must be a StructuralDBElement type!');
}
- if (null !== $parent && !is_a($parent, $class_name)) {
+ if ($parent instanceof AbstractStructuralDBElement && !$parent instanceof $class_name) {
throw new InvalidArgumentException('$parent must have the same type as specified in $class_name!');
}
+ //Ensure that parent is already persisted. Otherwise the getNewEntityFromPath function will not work.
+ if ($parent !== null && $parent->getID() === null) {
+ throw new InvalidArgumentException('The parent must persisted to database!');
+ }
+
+ $repo = $this->em->getRepository($class_name);
+
$errors = [];
$valid_entities = [];
@@ -78,10 +100,10 @@ class EntityImporter
$indentations = [0];
foreach ($names as $name) {
- //Count intendation level (whitespace characters at the beginning of the line)
+ //Count indentation level (whitespace characters at the beginning of the line)
$identSize = strlen($name)-strlen(ltrim($name));
- //If the line is intendet more than the last line, we have a new parent element
+ //If the line is intended more than the last line, we have a new parent element
if ($identSize > end($indentations)) {
$current_parent = $last_element;
//Add the new indentation level to the stack
@@ -89,11 +111,7 @@ class EntityImporter
}
while ($identSize < end($indentations)) {
//If the line is intendet less than the last line, we have to go up in the tree
- if ($current_parent instanceof AbstractStructuralDBElement) {
- $current_parent = $current_parent->getParent();
- } else {
- $current_parent = null;
- }
+ $current_parent = $current_parent instanceof AbstractStructuralDBElement ? $current_parent->getParent() : null;
array_pop($indentations);
}
@@ -102,14 +120,27 @@ class EntityImporter
//Skip empty lines (StrucuralDBElements must have a name)
continue;
}
+
/** @var AbstractStructuralDBElement $entity */
- //Create new element with given name
- $entity = new $class_name();
- $entity->setName($name);
- //Only set the parent if the entity is a StructuralDBElement
- if ($entity instanceof AbstractStructuralDBElement) {
- $entity->setParent($current_parent);
+ //Create new element with given name. Using the function from the repository, to correctly reuse existing elements
+
+ if ($current_parent instanceof AbstractStructuralDBElement) {
+ $new_path = $current_parent->getFullPath("->") . '->' . $name;
+ } else {
+ $new_path = $name;
}
+ //We can only use the getNewEntityFromPath function, if the repository is a StructuralDBElementRepository
+ if ($repo instanceof StructuralDBElementRepository) {
+ $entities = $repo->getNewEntityFromPath($new_path);
+ $entity = end($entities);
+ if ($entity === false) {
+ throw new InvalidArgumentException('getNewEntityFromPath returned an empty array!');
+ }
+ } else { //Otherwise just create a new entity
+ $entity = new $class_name;
+ $entity->setName($name);
+ }
+
//Validate entity
$tmp = $this->validator->validate($entity);
@@ -130,87 +161,45 @@ class EntityImporter
}
/**
- * This methods deserializes the given file and saves it database.
- * The imported elements will be checked (validated) before written to database.
- *
- * @param File $file the file that should be used for importing
- * @param string $class_name the class name of the enitity that should be imported
- * @param array $options options for the import process
- *
- * @return array An associative array containing an ConstraintViolationList and the entity name as key are returned,
- * if an error happened during validation. When everything was successfull, the array should be empty.
+ * Import data from a string.
+ * @param string $data The serialized data which should be imported
+ * @param array $options The options for the import process
+ * @param array $errors An array which will be filled with the validation errors, if any occurs during import
+ * @param-out array $errors
+ * @return array An array containing all valid imported entities
*/
- public function fileToDBEntities(File $file, string $class_name, array $options = []): array
+ public function importString(string $data, array $options = [], array &$errors = []): array
{
+ //Try to detect the text encoding of the data and convert it to UTF-8
+ $data = mb_convert_encoding($data, 'UTF-8', mb_detect_encoding($data, self::ENCODINGS));
+
$resolver = new OptionsResolver();
$this->configureOptions($resolver);
-
$options = $resolver->resolve($options);
- $entities = $this->fileToEntityArray($file, $class_name, $options);
-
- $errors = [];
-
- //Iterate over each $entity write it to DB.
- foreach ($entities as $entity) {
- /** @var AbstractStructuralDBElement $entity */
- //Move every imported entity to the selected parent
- $entity->setParent($options['parent']);
-
- //Validate entity
- $tmp = $this->validator->validate($entity);
-
- //When no validation error occured, persist entity to database (cascade must be set in entity)
- if (null === $tmp) {
- $this->em->persist($entity);
- } else { //Log validation errors to global log.
- $errors[$entity->getFullPath()] = $tmp;
- }
+ if (!is_a($options['class'], AbstractNamedDBElement::class, true)) {
+ throw new InvalidArgumentException('$class_name must be an AbstractNamedDBElement type!');
}
- //Save changes to database, when no error happened, or we should continue on error.
- if (empty($errors) || false === $options['abort_on_validation_error']) {
- $this->em->flush();
- }
-
- return $errors;
- }
-
- /**
- * This method converts (deserialize) a (uploaded) file to an array of entities with the given class.
- *
- * The imported elements will NOT be validated. If you want to use the result array, you have to validate it by yourself.
- *
- * @param File $file the file that should be used for importing
- * @param string $class_name the class name of the enitity that should be imported
- * @param array $options options for the import process
- *
- * @return array an array containing the deserialized elements
- */
- public function fileToEntityArray(File $file, string $class_name, array $options = []): array
- {
- $resolver = new OptionsResolver();
- $this->configureOptions($resolver);
-
- $options = $resolver->resolve($options);
-
- //Read file contents
- $content = file_get_contents($file->getRealPath());
-
- $groups = ['simple'];
+ $groups = ['import']; //We can only import data, that is marked with the group "import"
//Add group when the children should be preserved
if ($options['preserve_children']) {
$groups[] = 'include_children';
}
//The [] behind class_name denotes that we expect an array.
- $entities = $this->serializer->deserialize($content, $class_name.'[]', $options['format'],
+ $entities = $this->serializer->deserialize($data, $options['class'].'[]', $options['format'],
[
'groups' => $groups,
- 'csv_delimiter' => $options['csv_separator'],
+ 'csv_delimiter' => $options['csv_delimiter'],
+ 'create_unknown_datastructures' => $options['create_unknown_datastructures'],
+ 'path_delimiter' => $options['path_delimiter'],
+ 'partdb_import' => true,
+ //Disable API Platform normalizer, as we don't want to use it here
+ SkippableItemNormalizer::DISABLE_ITEM_NORMALIZER => true,
]);
- //Ensure we have an array of entitity elements.
+ //Ensure we have an array of entity elements.
if (!is_array($entities)) {
$entities = [$entities];
}
@@ -220,18 +209,146 @@ class EntityImporter
$this->correctParentEntites($entities, null);
}
+ //Set the parent of the imported elements to the given options
+ foreach ($entities as $entity) {
+ if ($entity instanceof AbstractStructuralDBElement) {
+ $entity->setParent($options['parent']);
+ }
+ if ($entity instanceof Part) {
+ if ($options['part_category']) {
+ $entity->setCategory($options['part_category']);
+ }
+ if ($options['part_needs_review']) {
+ $entity->setNeedsReview(true);
+ }
+ }
+ }
+
+ //Validate the entities
+ $errors = [];
+
+ //Iterate over each $entity write it to DB.
+ foreach ($entities as $key => $entity) {
+ //Ensure that entity is a NamedDBElement
+ if (!$entity instanceof AbstractNamedDBElement) {
+ throw new \RuntimeException("Encountered an entity that is not a NamedDBElement!");
+ }
+
+ //Validate entity
+ $tmp = $this->validator->validate($entity);
+
+ if (count($tmp) > 0) { //Log validation errors to global log.
+ $name = $entity instanceof AbstractStructuralDBElement ? $entity->getFullPath() : $entity->getName();
+
+ if (trim($name) === '') {
+ $name = 'Row ' . (string) $key;
+ }
+
+ $errors[$name] = [
+ 'violations' => $tmp,
+ 'entity' => $entity,
+ ];
+
+ //Remove the invalid entity from the array
+ unset($entities[$key]);
+ }
+ }
+
return $entities;
}
- protected function configureOptions(OptionsResolver $resolver): void
+ protected function configureOptions(OptionsResolver $resolver): OptionsResolver
{
$resolver->setDefaults([
- 'csv_separator' => ';',
- 'format' => 'json',
+ 'csv_delimiter' => ';', //The separator to use when importing csv files
+ 'format' => 'json', //The format of the file that should be imported
+ 'class' => AbstractNamedDBElement::class,
'preserve_children' => true,
- 'parent' => null,
+ 'parent' => null, //The parent element to which the imported elements should be added
'abort_on_validation_error' => true,
+ 'part_category' => null,
+ 'part_needs_review' => false, //If true, the imported parts will be marked as "needs review", otherwise the value from the file will be used
+ 'create_unknown_datastructures' => true, //If true, unknown datastructures (categories, footprints, etc.) will be created on the fly
+ 'path_delimiter' => '->', //The delimiter used to separate the path elements in the name of a structural element
]);
+
+ $resolver->setAllowedValues('format', ['csv', 'json', 'xml', 'yaml']);
+ $resolver->setAllowedTypes('csv_delimiter', 'string');
+ $resolver->setAllowedTypes('preserve_children', 'bool');
+ $resolver->setAllowedTypes('class', 'string');
+ $resolver->setAllowedTypes('part_category', [Category::class, 'null']);
+ $resolver->setAllowedTypes('part_needs_review', 'bool');
+
+ return $resolver;
+ }
+
+ /**
+ * This method deserializes the given file and writes the entities to the database (and flush the db).
+ * The imported elements will be checked (validated) before written to database.
+ *
+ * @param File $file the file that should be used for importing
+ * @param array $options options for the import process
+ * @param-out AbstractNamedDBElement[] $entities The imported entities are returned in this array
+ *
+ * @return array An associative array containing an ConstraintViolationList and the entity name as key are returned,
+ * if an error happened during validation. When everything was successfully, the array should be empty.
+ */
+ public function importFileAndPersistToDB(File $file, array $options = [], array &$entities = []): array
+ {
+ $options = $this->configureOptions(new OptionsResolver())->resolve($options);
+
+ $errors = [];
+ $entities = $this->importFile($file, $options, $errors);
+
+ //When we should abort on validation error, do nothing and return the errors
+ if (!empty($errors) && $options['abort_on_validation_error']) {
+ return $errors;
+ }
+
+ //Iterate over each $entity write it to DB (the invalid entities were already filtered out).
+ foreach ($entities as $entity) {
+ $this->em->persist($entity);
+ }
+
+ //Save changes to database, when no error happened, or we should continue on error.
+ $this->em->flush();
+
+ return $errors;
+ }
+
+ /**
+ * This method converts (deserialize) a (uploaded) file to an array of entities with the given class.
+ * The imported elements are not persisted to database yet, so you have to do it yourself.
+ *
+ * @param File $file the file that should be used for importing
+ * @param array $options options for the import process
+ * @param-out array $errors
+ *
+ * @return AbstractNamedDBElement[] an array containing the deserialized elements
+ */
+ public function importFile(File $file, array $options = [], array &$errors = []): array
+ {
+ return $this->importString($file->getContent(), $options, $errors);
+ }
+
+
+ /**
+ * Determines the format to import based on the file extension.
+ * @param string $extension The file extension to use
+ * @return string The format to use (json, xml, csv, yaml), or null if the extension is unknown
+ */
+ public function determineFormat(string $extension): ?string
+ {
+ //Convert the extension to lower case
+ $extension = strtolower($extension);
+
+ return match ($extension) {
+ 'json' => 'json',
+ 'xml' => 'xml',
+ 'csv', 'tsv' => 'csv',
+ 'yaml', 'yml' => 'yaml',
+ default => null,
+ };
}
/**
@@ -240,7 +357,7 @@ class EntityImporter
* @param iterable $entities the list of entities that should be fixed
* @param AbstractStructuralDBElement|null $parent the parent, to which the entity should be set
*/
- protected function correctParentEntites(iterable $entities, AbstractStructuralDBElement $parent = null): void
+ protected function correctParentEntites(iterable $entities, ?AbstractStructuralDBElement $parent = null): void
{
foreach ($entities as $entity) {
/** @var AbstractStructuralDBElement $entity */
diff --git a/src/Services/ImportExportSystem/PartKeeprImporter/MySQLDumpXMLConverter.php b/src/Services/ImportExportSystem/PartKeeprImporter/MySQLDumpXMLConverter.php
new file mode 100644
index 00000000..f221ee89
--- /dev/null
+++ b/src/Services/ImportExportSystem/PartKeeprImporter/MySQLDumpXMLConverter.php
@@ -0,0 +1,112 @@
+.
+ */
+namespace App\Services\ImportExportSystem\PartKeeprImporter;
+
+class MySQLDumpXMLConverter
+{
+
+ /**
+ * Converts a MySQL dump XML file to an associative array structure in the following form
+ * [
+ * 'table_name' => [
+ * [
+ * 'column_name' => 'value',
+ * 'column_name' => 'value',
+ * ...
+ * ],
+ * [
+ * 'column_name' => 'value',
+ * 'column_name' => 'value',
+ * ...
+ * ],
+ * ...
+ * ],
+ *
+ * @param string $xml_string The XML string to convert
+ * @return array The associative array structure
+ */
+ public function convertMySQLDumpXMLDataToArrayStructure(string $xml_string): array
+ {
+ $dom = new \DOMDocument();
+ $dom->loadXML($xml_string);
+
+ //Check that the root node is a node
+ $root = $dom->documentElement;
+ if ($root->nodeName !== 'mysqldump') {
+ throw new \InvalidArgumentException('The given XML string is not a valid MySQL dump XML file!');
+ }
+
+ //Get all nodes (there must be exactly one)
+ $databases = $root->getElementsByTagName('database');
+ if ($databases->length !== 1) {
+ throw new \InvalidArgumentException('The given XML string is not a valid MySQL dump XML file!');
+ }
+
+ //Get the node
+ $database = $databases->item(0);
+
+ //Get all nodes
+ $tables = $database->getElementsByTagName('table_data');
+ $table_data = [];
+
+ //Iterate over all
nodes and convert them to arrays
+ foreach ($tables as $table) {
+ //Normalize the table name to lowercase. On Linux filesystems the tables sometimes contain uppercase letters
+ //However we expect the table names to be lowercase in the further steps
+ $table_name = strtolower($table->getAttribute('name'));
+ $table_data[$table_name] = $this->convertTableToArray($table);
+ }
+
+ return $table_data;
+ }
+
+ private function convertTableToArray(\DOMElement $table): array
+ {
+ $table_data = [];
+
+ //Get all nodes
+ $rows = $table->getElementsByTagName('row');
+
+ //Iterate over all nodes and convert them to arrays
+ foreach ($rows as $row) {
+ $table_data[] = $this->convertTableRowToArray($row);
+ }
+
+ return $table_data;
+ }
+
+ private function convertTableRowToArray(\DOMElement $table_row): array
+ {
+ $row_data = [];
+
+ //Get all nodes
+ $fields = $table_row->getElementsByTagName('field');
+
+ //Iterate over all nodes
+ foreach ($fields as $field) {
+ $row_data[$field->getAttribute('name')] = $field->nodeValue;
+ }
+
+ return $row_data;
+ }
+}
diff --git a/src/Services/ImportExportSystem/PartKeeprImporter/PKDatastructureImporter.php b/src/Services/ImportExportSystem/PartKeeprImporter/PKDatastructureImporter.php
new file mode 100644
index 00000000..1f842c23
--- /dev/null
+++ b/src/Services/ImportExportSystem/PartKeeprImporter/PKDatastructureImporter.php
@@ -0,0 +1,264 @@
+.
+ */
+namespace App\Services\ImportExportSystem\PartKeeprImporter;
+
+use App\Entity\Attachments\FootprintAttachment;
+use App\Entity\Attachments\ManufacturerAttachment;
+use App\Entity\Attachments\StorageLocationAttachment;
+use App\Entity\Parts\Category;
+use App\Entity\Parts\Footprint;
+use App\Entity\Parts\Manufacturer;
+use App\Entity\Parts\MeasurementUnit;
+use App\Entity\Parts\StorageLocation;
+use App\Entity\Parts\Supplier;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
+
+use function count;
+
+/**
+ * This service is used to import the datastructures (categories, manufacturers, etc.) from a PartKeepr export.
+ */
+class PKDatastructureImporter
+{
+
+ use PKImportHelperTrait;
+
+ public function __construct(EntityManagerInterface $em, PropertyAccessorInterface $propertyAccessor)
+ {
+ $this->em = $em;
+ $this->propertyAccessor = $propertyAccessor;
+ }
+
+
+ /**
+ * Imports the distributors from the given data.
+ * @param array $data The data to import (associated array, containing a 'distributor' key
+ * @return int The number of imported distributors
+ */
+ public function importDistributors(array $data): int
+ {
+ if (!isset($data['distributor'])) {
+ throw new \RuntimeException('$data must contain a "distributor" key!');
+ }
+
+ $distributor_data = $data['distributor'];
+
+ foreach ($distributor_data as $distributor) {
+ $supplier = new Supplier();
+ $supplier->setName($distributor['name']);
+ $supplier->setWebsite($distributor['url'] ?? '');
+ $supplier->setAddress($distributor['address'] ?? '');
+ $supplier->setPhoneNumber($distributor['phone'] ?? '');
+ $supplier->setFaxNumber($distributor['fax'] ?? '');
+ $supplier->setEmailAddress($distributor['email'] ?? '');
+ $supplier->setComment($distributor['comment']);
+ $supplier->setAutoProductUrl($distributor['skuurl'] ?? '');
+
+ $this->setIDOfEntity($supplier, $distributor['id']);
+ $this->em->persist($supplier);
+ }
+
+ $this->em->flush();
+
+ return is_countable($distributor_data) ? count($distributor_data) : 0;
+ }
+
+ public function importManufacturers(array $data): int
+ {
+ if (!isset($data['manufacturer'])) {
+ throw new \RuntimeException('$data must contain a "manufacturer" key!');
+ }
+
+ $manufacturer_data = $data['manufacturer'];
+
+ $max_id = 0;
+
+ //Assign a parent manufacturer to all manufacturers, as partkeepr has a lot of manufacturers by default
+ $parent_manufacturer = new Manufacturer();
+ $parent_manufacturer->setName('PartKeepr');
+ $parent_manufacturer->setNotSelectable(true);
+
+ foreach ($manufacturer_data as $manufacturer) {
+ $entity = new Manufacturer();
+ $entity->setName($manufacturer['name']);
+ $entity->setWebsite($manufacturer['url'] ?? '');
+ $entity->setAddress($manufacturer['address'] ?? '');
+ $entity->setPhoneNumber($manufacturer['phone'] ?? '');
+ $entity->setFaxNumber($manufacturer['fax'] ?? '');
+ $entity->setEmailAddress($manufacturer['email'] ?? '');
+ $entity->setComment($manufacturer['comment']);
+ $entity->setParent($parent_manufacturer);
+
+ $this->setIDOfEntity($entity, $manufacturer['id']);
+ $this->em->persist($entity);
+
+ $max_id = max($max_id, $manufacturer['id']);
+ }
+
+ //Set the ID of the parent manufacturer to the max ID + 1, to avoid trouble with the auto increment
+ $this->setIDOfEntity($parent_manufacturer, $max_id + 1);
+ $this->em->persist($parent_manufacturer);
+
+ $this->em->flush();
+
+ $this->importAttachments($data, 'manufacturericlogo', Manufacturer::class, 'manufacturer_id', ManufacturerAttachment::class);
+
+ return is_countable($manufacturer_data) ? count($manufacturer_data) : 0;
+ }
+
+ public function importPartUnits(array $data): int
+ {
+ if (!isset($data['partunit'])) {
+ throw new \RuntimeException('$data must contain a "partunit" key!');
+ }
+
+ $partunit_data = $data['partunit'];
+ foreach ($partunit_data as $partunit) {
+ $unit = new MeasurementUnit();
+ $unit->setName($partunit['name']);
+ $unit->setUnit($partunit['shortName'] ?? null);
+
+ $this->setIDOfEntity($unit, $partunit['id']);
+ $this->em->persist($unit);
+ }
+
+ $this->em->flush();
+
+ return is_countable($partunit_data) ? count($partunit_data) : 0;
+ }
+
+ public function importCategories(array $data): int
+ {
+ if (!isset($data['partcategory'])) {
+ throw new \RuntimeException('$data must contain a "partcategory" key!');
+ }
+
+ $partcategory_data = $data['partcategory'];
+
+ //In a first step, create all categories like they were a flat structure (so ignore the parent)
+ foreach ($partcategory_data as $partcategory) {
+ $category = new Category();
+ $category->setName($partcategory['name']);
+ $category->setComment($partcategory['description']);
+
+ $this->setIDOfEntity($category, $partcategory['id']);
+ $this->em->persist($category);
+ }
+
+ $this->em->flush();
+
+ //In a second step, set the correct parent element
+ foreach ($partcategory_data as $partcategory) {
+ $this->setParent(Category::class, $partcategory['id'], $partcategory['parent_id']);
+ }
+ $this->em->flush();
+
+ return is_countable($partcategory_data) ? count($partcategory_data) : 0;
+ }
+
+ /**
+ * The common import functions for footprints and storeloactions
+ */
+ private function importElementsWithCategory(array $data, string $target_class, string $data_prefix): int
+ {
+ $key = $data_prefix;
+ $category_key = $data_prefix.'category';
+
+ if (!isset($data[$key])) {
+ throw new \RuntimeException('$data must contain a "'. $key .'" key!');
+ }
+ if (!isset($data[$category_key])) {
+ throw new \RuntimeException('$data must contain a "'. $category_key .'" key!');
+ }
+
+ //We import the footprints first, as we need the IDs of the footprints be our real DBs later (as we match the part import by ID)
+ //As the footprints category is not existing yet, we just skip the parent field for now
+ $footprint_data = $data[$key];
+ $max_footprint_id = 0;
+ foreach ($footprint_data as $footprint) {
+ $entity = new $target_class();
+ $entity->setName($footprint['name']);
+ $entity->setComment($footprint['description'] ?? '');
+
+ $this->setIDOfEntity($entity, $footprint['id']);
+ $this->em->persist($entity);
+ $max_footprint_id = max($max_footprint_id, (int) $footprint['id']);
+ }
+
+ //Import the footprint categories ignoring the parents for now
+ //Their IDs are $max_footprint_id + $ID
+ $footprintcategory_data = $data[$category_key];
+ foreach ($footprintcategory_data as $footprintcategory) {
+ $entity = new $target_class();
+ $entity->setName($footprintcategory['name']);
+ $entity->setComment($footprintcategory['description']);
+ //Categories are not assignable to parts, so we set them to not selectable
+ $entity->setNotSelectable(true);
+
+ $this->setIDOfEntity($entity, $max_footprint_id + (int) $footprintcategory['id']);
+ $this->em->persist($entity);
+ }
+
+ $this->em->flush();
+
+ //Now we can correct the parents and category IDs of the parts
+ foreach ($footprintcategory_data as $footprintcategory) {
+ //We have to use the mapped IDs here, as the imported ID is not the effective ID
+ if ($footprintcategory['parent_id']) {
+ $this->setParent($target_class, $max_footprint_id + (int)$footprintcategory['id'],
+ $max_footprint_id + (int)$footprintcategory['parent_id']);
+ }
+ }
+ foreach ($footprint_data as $footprint) {
+ if ($footprint['category_id']) {
+ $this->setParent($target_class, $footprint['id'],
+ $max_footprint_id + (int)$footprint['category_id']);
+ }
+ }
+
+ $this->em->flush();
+
+ return (is_countable($footprint_data) ? count($footprint_data) : 0) + (is_countable($footprintcategory_data) ? count($footprintcategory_data) : 0);
+ }
+
+ public function importFootprints(array $data): int
+ {
+ $count = $this->importElementsWithCategory($data, Footprint::class, 'footprint');
+
+ //Footprints have both attachments and images
+ $this->importAttachments($data, 'footprintattachment', Footprint::class, 'footprint_id', FootprintAttachment::class);
+ $this->importAttachments($data, 'footprintimage', Footprint::class, 'footprint_id', FootprintAttachment::class);
+
+ return $count;
+ }
+
+ public function importStorelocations(array $data): int
+ {
+ $count = $this->importElementsWithCategory($data, StorageLocation::class, 'storagelocation');
+
+ $this->importAttachments($data, 'storagelocationimage', StorageLocation::class, 'storageLocation_id', StorageLocationAttachment::class);
+
+ return $count;
+ }
+}
diff --git a/src/Services/ImportExportSystem/PartKeeprImporter/PKImportHelper.php b/src/Services/ImportExportSystem/PartKeeprImporter/PKImportHelper.php
new file mode 100644
index 00000000..f36e48ce
--- /dev/null
+++ b/src/Services/ImportExportSystem/PartKeeprImporter/PKImportHelper.php
@@ -0,0 +1,69 @@
+.
+ */
+namespace App\Services\ImportExportSystem\PartKeeprImporter;
+
+use App\Doctrine\Purger\ResetAutoIncrementORMPurger;
+use Doctrine\ORM\EntityManagerInterface;
+
+/**
+ * This service contains various helper functions for the PartKeeprImporter (like purging the database).
+ */
+class PKImportHelper
+{
+ public function __construct(protected EntityManagerInterface $em)
+ {
+ }
+
+ /**
+ * Purges the database tables for the import, so that all data can be created from scratch.
+ * Existing users and groups are not purged.
+ * This is needed to avoid ID collisions.
+ */
+ public function purgeDatabaseForImport(): void
+ {
+ //We use the ResetAutoIncrementORMPurger to reset the auto increment values of the tables. Also it normalizes table names before checking for exclusion.
+ $purger = new ResetAutoIncrementORMPurger($this->em, ['users', 'groups', 'u2f_keys', 'internal', 'migration_versions']);
+ $purger->purge();
+ }
+
+ /**
+ * Extracts the current database schema version from the PartKeepr XML dump.
+ */
+ public function getDatabaseSchemaVersion(array $data): string
+ {
+ if (!isset($data['schemaversions'])) {
+ throw new \RuntimeException('Could not find schema version in XML dump!');
+ }
+
+ return end($data['schemaversions'])['version'];
+ }
+
+ /**
+ * Checks that the database schema of the PartKeepr XML dump is compatible with the importer
+ * @return bool True if the schema is compatible, false otherwise
+ */
+ public function checkVersion(array $data): bool
+ {
+ return $this->getDatabaseSchemaVersion($data) === '20170601175559';
+ }
+}
diff --git a/src/Services/ImportExportSystem/PartKeeprImporter/PKImportHelperTrait.php b/src/Services/ImportExportSystem/PartKeeprImporter/PKImportHelperTrait.php
new file mode 100644
index 00000000..1e4cd3ba
--- /dev/null
+++ b/src/Services/ImportExportSystem/PartKeeprImporter/PKImportHelperTrait.php
@@ -0,0 +1,257 @@
+.
+ */
+namespace App\Services\ImportExportSystem\PartKeeprImporter;
+
+use Doctrine\ORM\Id\AssignedGenerator;
+use App\Entity\Attachments\Attachment;
+use App\Entity\Attachments\AttachmentContainingDBElement;
+use App\Entity\Attachments\AttachmentType;
+use App\Entity\Base\AbstractDBElement;
+use App\Entity\Base\AbstractStructuralDBElement;
+use App\Entity\Contracts\TimeStampableInterface;
+use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\Mapping\ClassMetadata;
+use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
+
+/**
+ * This trait contains helper functions for the PartKeeprImporter.
+ */
+trait PKImportHelperTrait
+{
+ protected EntityManagerInterface $em;
+ protected PropertyAccessorInterface $propertyAccessor;
+
+ private ?AttachmentType $import_attachment_type = null;
+
+ /**
+ * Converts a PartKeepr attachment/image row to an Attachment entity.
+ * @param array $attachment_row The attachment row from the PartKeepr database
+ * @param string $target_class The target class for the attachment
+ * @param string $type The type of the attachment (attachment or image)
+ * @throws \Exception
+ */
+ protected function convertAttachmentDataToEntity(array $attachment_row, string $target_class, string $type): Attachment
+ {
+ //By default, we use the cached version
+ if (!$this->import_attachment_type) {
+ //Get the import attachment type
+ $this->import_attachment_type = $this->em->getRepository(AttachmentType::class)->findOneBy([
+ 'name' => 'PartKeepr Attachment'
+ ]);
+ if (!$this->import_attachment_type) { //If not existing in DB create it
+ $this->import_attachment_type = new AttachmentType();
+ $this->import_attachment_type->setName('PartKeepr Attachment');
+ $this->em->persist($this->import_attachment_type);
+ }
+ }
+
+ if (!in_array($type, ['attachment', 'image'], true)) {
+ throw new \InvalidArgumentException(sprintf('The type %s is not a valid attachment type', $type));
+ }
+
+ if (!is_a($target_class, Attachment::class, true)) {
+ throw new \InvalidArgumentException(sprintf('The target class %s is not a subclass of %s', $target_class, Attachment::class));
+ }
+
+ /** @var Attachment $attachment */
+ $attachment = new $target_class();
+ if (!empty($attachment_row['description'])) {
+ $attachment->setName($attachment_row['description']);
+ } else {
+ $attachment->setName($attachment_row['originalname']);
+ }
+ $attachment->setFilename($attachment_row['originalname']);
+ $attachment->setAttachmentType($this->import_attachment_type);
+ $this->setCreationDate($attachment, $attachment_row['created']);
+
+ //Determine file extension (if the extension is empty, we use the original extension)
+ if (empty($attachment_row['extension'])) {
+ //Use mime type to determine the extension like PartKeepr does in legacy implementation (just use the second part of the mime type)
+ //See UploadedFile.php:291 in PartKeepr (https://github.com/partkeepr/PartKeepr/blob/f6176c3354b24fa39ac8bc4328ee0df91de3d5b6/src/PartKeepr/UploadedFileBundle/Entity/UploadedFile.php#L291)
+ if (!empty ($attachment_row['mimetype'])) {
+ $attachment_row['extension'] = explode('/', (string) $attachment_row['mimetype'])[1];
+ } else {
+ //If the mime type is empty, we use the original extension
+ $attachment_row['extension'] = pathinfo((string) $attachment_row['originalname'], PATHINFO_EXTENSION);
+ }
+
+ }
+
+ //Determine file path
+ //Images are stored in the (public) media folder, attachments in the (private) uploads/ folder
+ $path = $type === 'attachment' ? '%SECURE%' : '%MEDIA%';
+ //The folder is the type of the attachment from the PartKeepr database
+ $path .= '/'.$attachment_row['type'];
+ //Next comes the filename plus extension
+ $path .= '/'.$attachment_row['filename'].'.'.$attachment_row['extension'];
+
+ $attachment->setInternalPath($path);
+
+ return $attachment;
+ }
+
+ /**
+ * Imports the attachments from the given data
+ * @param array $data The PartKeepr database
+ * @param string $table_name The table name for the attachments (if it contains "image", it will be treated as an image)
+ * @param string $target_class The target class (e.g. Part)
+ * @param string $target_id_field The field name where the target ID is stored
+ * @param string $attachment_class The attachment class (e.g. PartAttachment)
+ */
+ protected function importAttachments(array $data, string $table_name, string $target_class, string $target_id_field, string $attachment_class): void
+ {
+ //Determine if we have an image or an attachment
+ $type = str_contains($table_name, 'image') || str_contains($table_name, 'iclogo') ? 'image' : 'attachment';
+
+ if (!isset($data[$table_name])) {
+ throw new \RuntimeException(sprintf('The table %s does not exist in the PartKeepr database', $table_name));
+ }
+
+ if (!is_a($target_class, AttachmentContainingDBElement::class, true)) {
+ throw new \InvalidArgumentException(sprintf('The target class %s is not a subclass of %s', $target_class, AttachmentContainingDBElement::class));
+ }
+
+ if (!is_a($attachment_class, Attachment::class, true)) {
+ throw new \InvalidArgumentException(sprintf('The attachment class %s is not a subclass of %s', $attachment_class, Attachment::class));
+ }
+
+ //Get the table data
+ $table_data = $data[$table_name];
+ foreach($table_data as $attachment_row) {
+ $attachment = $this->convertAttachmentDataToEntity($attachment_row, $attachment_class, $type);
+
+ //Retrieve the target entity
+ $target_id = (int) $attachment_row[$target_id_field];
+ /** @var AttachmentContainingDBElement $target */
+ $target = $this->em->find($target_class, $target_id);
+ if (!$target) {
+ throw new \RuntimeException(sprintf('Could not find target entity with ID %s', $target_id));
+ }
+
+ $target->addAttachment($attachment);
+ $this->em->persist($attachment);
+ }
+
+ $this->em->flush();
+ }
+
+ /**
+ * Assigns the parent to the given entity, using the numerical IDs from the imported data.
+ * @return AbstractStructuralDBElement The structural element that was modified (with $element_id)
+ */
+ protected function setParent(string $class, int|string $element_id, int|string $parent_id): AbstractStructuralDBElement
+ {
+ $element = $this->em->find($class, (int) $element_id);
+ if (!$element) {
+ throw new \RuntimeException(sprintf('Could not find element with ID %s', $element_id));
+ }
+
+ //If the parent is null, we're done
+ if (!$parent_id) {
+ return $element;
+ }
+
+ $parent = $this->em->find($class, (int) $parent_id);
+ if (!$parent) {
+ throw new \RuntimeException(sprintf('Could not find parent with ID %s', $parent_id));
+ }
+
+ $element->setParent($parent);
+ return $element;
+ }
+
+ /**
+ * Sets the given field of the given entity to the entity with the given ID.
+ */
+ protected function setAssociationField(AbstractDBElement $element, string $field, string $other_class, $other_id): AbstractDBElement
+ {
+ //If the parent is null, set the field to null and we're done
+ if (!$other_id) {
+ $this->propertyAccessor->setValue($element, $field, null);
+ return $element;
+ }
+
+ $parent = $this->em->find($other_class, (int) $other_id);
+ if (!$parent) {
+ throw new \RuntimeException(sprintf('Could not find other_class with ID %s', $other_id));
+ }
+
+ $this->propertyAccessor->setValue($element, $field, $parent);
+ return $element;
+ }
+
+ /**
+ * Set the ID of an entity to a specific value. Must be called before persisting the entity, but before flushing.
+ */
+ protected function setIDOfEntity(AbstractDBElement $element, int|string $id): void
+ {
+ $id = (int) $id;
+
+ $metadata = $this->em->getClassMetadata($element::class);
+ $metadata->setIdGeneratorType(ClassMetadata::GENERATOR_TYPE_NONE);
+ $metadata->setIdGenerator(new AssignedGenerator());
+ $metadata->setIdentifierValues($element, ['id' => $id]);
+ }
+
+ /**
+ * Sets the creation date of an entity to a specific value.
+ * @return void
+ * @throws \Exception
+ */
+ protected function setCreationDate(TimeStampableInterface $entity, ?string $datetime_str): void
+ {
+ if ($datetime_str !== null && $datetime_str !== '' && $datetime_str !== '0000-00-00 00:00:00') {
+ $date = new \DateTimeImmutable($datetime_str);
+ } else {
+ $date = null; //Null means "now" at persist time
+ }
+
+ $reflectionClass = new \ReflectionClass($entity);
+ $property = $reflectionClass->getProperty('addedDate');
+ $property->setAccessible(true);
+ $property->setValue($entity, $date);
+ }
+
+ /**
+ * Gets the SI prefix factor for the given prefix ID.
+ * Used to convert a value from the PartKeepr database to the PartDB database.
+ * @param array $data
+ * @param int $prefix_id
+ * @return float
+ */
+ protected function getSIPrefixFactor(array $data, int $prefix_id): float
+ {
+ if ($prefix_id === 0) {
+ return 1.0;
+ }
+
+ $prefixes = $data['siprefix'];
+ foreach ($prefixes as $prefix) {
+ if ((int) $prefix['id'] === $prefix_id) {
+ return (int)$prefix['base'] ** (int)$prefix['exponent'];
+ }
+ }
+
+ throw new \RuntimeException(sprintf('Could not find SI prefix with ID %s', $prefix_id));
+ }
+}
diff --git a/src/Services/ImportExportSystem/PartKeeprImporter/PKOptionalImporter.php b/src/Services/ImportExportSystem/PartKeeprImporter/PKOptionalImporter.php
new file mode 100644
index 00000000..fafde29a
--- /dev/null
+++ b/src/Services/ImportExportSystem/PartKeeprImporter/PKOptionalImporter.php
@@ -0,0 +1,149 @@
+.
+ */
+namespace App\Services\ImportExportSystem\PartKeeprImporter;
+
+use App\Entity\Attachments\ProjectAttachment;
+use App\Entity\Parts\Part;
+use App\Entity\ProjectSystem\Project;
+use App\Entity\ProjectSystem\ProjectBOMEntry;
+use App\Entity\UserSystem\Group;
+use App\Entity\UserSystem\User;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
+
+/**
+ * This service is used to other non-mandatory data from a PartKeepr export.
+ * You have to import the datastructures and parts first to use project import!
+ */
+class PKOptionalImporter
+{
+ use PKImportHelperTrait;
+
+ public function __construct(EntityManagerInterface $em, PropertyAccessorInterface $propertyAccessor)
+ {
+ $this->em = $em;
+ $this->propertyAccessor = $propertyAccessor;
+ }
+
+ /**
+ * Import the projects from the given data.
+ * @return int The number of imported projects
+ */
+ public function importProjects(array $data): int
+ {
+ if (!isset($data['project'])) {
+ throw new \RuntimeException('$data must contain a "project" key!');
+ }
+ if (!isset($data['projectpart'])) {
+ throw new \RuntimeException('$data must contain a "projectpart" key!');
+ }
+
+ $projects_data = $data['project'];
+ $projectparts_data = $data['projectpart'];
+
+ //First import the projects
+ foreach ($projects_data as $project_data) {
+ $project = new Project();
+ $project->setName($project_data['name']);
+ $project->setDescription($project_data['description'] ?? '');
+
+ $this->setIDOfEntity($project, $project_data['id']);
+ $this->em->persist($project);
+ }
+ $this->em->flush();
+
+ //Then the project BOM entries
+ foreach ($projectparts_data as $projectpart_data) {
+ /** @var Project $project */
+ $project = $this->em->find(Project::class, (int) $projectpart_data['project_id']);
+ if (!$project) {
+ throw new \RuntimeException('Could not find project with ID '.$projectpart_data['project_id']);
+ }
+
+ $bom_entry = new ProjectBOMEntry();
+ $bom_entry->setQuantity((float) $projectpart_data['quantity']);
+ $bom_entry->setName($projectpart_data['remarks']);
+ $this->setAssociationField($bom_entry, 'part', Part::class, $projectpart_data['part_id']);
+
+ $comments = [];
+ if (!empty($projectpart_data['lotNumber'])) {
+ $comments[] = 'Lot number: '.$projectpart_data['lotNumber'];
+ }
+ if (!empty($projectpart_data['overage'])) {
+ $comments[] = 'Overage: '.$projectpart_data['overage'].($projectpart_data['overageType'] ? ' %' : ' pcs');
+ }
+ $bom_entry->setComment(implode(',', $comments));
+
+ $project->addBomEntry($bom_entry);
+ }
+ $this->em->flush();
+
+ $this->importAttachments($data, 'projectattachment', Project::class, 'project_id', ProjectAttachment::class);
+
+ return is_countable($projects_data) ? count($projects_data) : 0;
+ }
+
+ /**
+ * Import the users from the given data.
+ * @return int The number of imported users
+ */
+ public function importUsers(array $data): int
+ {
+ if (!isset($data['fosuser'])) {
+ throw new \RuntimeException('$data must contain a "fosuser" key!');
+ }
+
+ //All imported users get assigned to the "PartKeepr Users" group
+ $group_users = $this->em->find(Group::class, 3);
+ $group = $this->em->getRepository(Group::class)->findOneBy(['name' => 'PartKeepr Users', 'parent' => $group_users]);
+ if ($group === null) {
+ $group = new Group();
+ $group->setName('PartKeepr Users');
+ $group->setParent($group_users);
+ $this->em->persist($group);
+ }
+
+
+ $users_data = $data['fosuser'];
+ foreach ($users_data as $user_data) {
+ if (in_array($user_data['username'], ['admin', 'anonymous'], true)) {
+ continue;
+ }
+
+ $user = new User();
+ $user->setName($user_data['username']);
+ $user->setEmail($user_data['email']);
+ $user->setGroup($group);
+
+ //User is disabled by default
+ $user->setDisabled(true);
+
+ //We let doctrine generate a new ID for the user
+ $this->em->persist($user);
+ }
+
+ $this->em->flush();
+
+ return is_countable($users_data) ? count($users_data) : 0;
+ }
+}
diff --git a/src/Services/ImportExportSystem/PartKeeprImporter/PKPartImporter.php b/src/Services/ImportExportSystem/PartKeeprImporter/PKPartImporter.php
new file mode 100644
index 00000000..9dd67233
--- /dev/null
+++ b/src/Services/ImportExportSystem/PartKeeprImporter/PKPartImporter.php
@@ -0,0 +1,317 @@
+.
+ */
+namespace App\Services\ImportExportSystem\PartKeeprImporter;
+
+use App\Entity\Attachments\PartAttachment;
+use App\Entity\Parameters\PartParameter;
+use App\Entity\Parts\Category;
+use App\Entity\Parts\Footprint;
+use App\Entity\Parts\Manufacturer;
+use App\Entity\Parts\MeasurementUnit;
+use App\Entity\Parts\Part;
+use App\Entity\Parts\PartLot;
+use App\Entity\Parts\StorageLocation;
+use App\Entity\Parts\Supplier;
+use App\Entity\PriceInformations\Currency;
+use App\Entity\PriceInformations\Orderdetail;
+use App\Entity\PriceInformations\Pricedetail;
+use Brick\Math\BigDecimal;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Component\Intl\Currencies;
+use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
+
+/**
+ * This service is used to import parts from a PartKeepr export. You have to import the datastructures first!
+ */
+class PKPartImporter
+{
+ use PKImportHelperTrait;
+
+ public function __construct(EntityManagerInterface $em, PropertyAccessorInterface $propertyAccessor, private readonly string $base_currency)
+ {
+ $this->em = $em;
+ $this->propertyAccessor = $propertyAccessor;
+ }
+
+ public function importParts(array $data): int
+ {
+ if (!isset($data['part'])) {
+ throw new \RuntimeException('$data must contain a "part" key!');
+ }
+
+
+ $part_data = $data['part'];
+ foreach ($part_data as $part) {
+ $entity = new Part();
+ $entity->setName($part['name']);
+ $entity->setDescription($part['description'] ?? '');
+ //All parts get a tag, that they were imported from PartKeepr
+ $entity->setTags('partkeepr-imported');
+ $this->setAssociationField($entity, 'category', Category::class, $part['category_id']);
+
+ //If the part is a metapart, write that in the description, and we can skip the rest
+ if ($part['metaPart'] === '1') {
+ $entity->setDescription('Metapart (Not supported in Part-DB)');
+ $entity->setComment('This part represents a former metapart in PartKeepr. It is not supported in Part-DB yet. And you can most likely delete it.');
+ $entity->setTags('partkeepr-imported,partkeepr-metapart');
+ } else {
+ $entity->setMinAmount((float) ($part['minStockLevel'] ?? 0));
+ if (!empty($part['internalPartNumber'])) {
+ $entity->setIpn($part['internalPartNumber']);
+ }
+ $entity->setComment($part['comment'] ?? '');
+ $entity->setNeedsReview($part['needsReview'] === '1');
+ $this->setCreationDate($entity, $part['createDate']);
+
+ $this->setAssociationField($entity, 'footprint', Footprint::class, $part['footprint_id']);
+
+ //Set partUnit (when it is not ID=1, which is Pieces in Partkeepr)
+ if ($part['partUnit_id'] !== '1') {
+ $this->setAssociationField($entity, 'partUnit', MeasurementUnit::class, $part['partUnit_id']);
+ }
+
+ //Create a part lot to store the stock level and location
+ $lot = new PartLot();
+ $lot->setAmount((float) ($part['stockLevel'] ?? 0));
+ $this->setAssociationField($lot, 'storage_location', StorageLocation::class, $part['storageLocation_id']);
+ $entity->addPartLot($lot);
+
+ //For partCondition, productionsRemarks and Status, create a custom parameter
+ if ($part['partCondition']) {
+ $partCondition = (new PartParameter())->setName('Part Condition')->setGroup('PartKeepr')
+ ->setValueText($part['partCondition']);
+ $entity->addParameter($partCondition);
+ }
+ if ($part['productionRemarks']) {
+ $partCondition = (new PartParameter())->setName('Production Remarks')->setGroup('PartKeepr')
+ ->setValueText($part['productionRemarks']);
+ $entity->addParameter($partCondition);
+ }
+ if ($part['status']) {
+ $partCondition = (new PartParameter())->setName('Status')->setGroup('PartKeepr')
+ ->setValueText($part['status']);
+ $entity->addParameter($partCondition);
+ }
+ }
+
+ $this->setIDOfEntity($entity, $part['id']);
+ $this->em->persist($entity);
+ }
+
+ $this->em->flush();
+
+ $this->importPartManufacturers($data);
+ $this->importPartParameters($data);
+ $this->importOrderdetails($data);
+
+ //Import attachments
+ $this->importAttachments($data, 'partattachment', Part::class, 'part_id', PartAttachment::class);
+
+ return is_countable($part_data) ? count($part_data) : 0;
+ }
+
+ protected function importPartManufacturers(array $data): void
+ {
+ if (!isset($data['partmanufacturer'])) {
+ throw new \RuntimeException('$data must contain a "partmanufacturer" key!');
+ }
+
+ //Part-DB only supports one manufacturer per part, only the last one is imported
+ $partmanufacturer_data = $data['partmanufacturer'];
+ foreach ($partmanufacturer_data as $partmanufacturer) {
+ /** @var Part $part */
+ $part = $this->em->find(Part::class, (int) $partmanufacturer['part_id']);
+ if (!$part) {
+ throw new \RuntimeException(sprintf('Could not find part with ID %s', $partmanufacturer['part_id']));
+ }
+ $manufacturer = $this->em->find(Manufacturer::class, (int) $partmanufacturer['manufacturer_id']);
+ //The manufacturer is optional
+ if (!$manufacturer instanceof Manufacturer && !empty($partmanufacturer['manufacturer_id'])) {
+ throw new \RuntimeException(sprintf('Could not find manufacturer with ID %s', $partmanufacturer['manufacturer_id']));
+ }
+ $part->setManufacturer($manufacturer);
+ $part->setManufacturerProductNumber($partmanufacturer['partNumber']);
+ }
+
+ $this->em->flush();
+ }
+
+ protected function importPartParameters(array $data): void
+ {
+ if (!isset($data['partparameter'])) {
+ throw new \RuntimeException('$data must contain a "partparameter" key!');
+ }
+
+ foreach ($data['partparameter'] as $partparameter) {
+ $entity = new PartParameter();
+
+ //Name format: Name (Description)
+ $name = $partparameter['name'];
+ if (!empty($partparameter['description'])) {
+ $name .= ' ('.$partparameter['description'].')';
+ }
+ $entity->setName($name);
+
+ $entity->setValueText($partparameter['stringValue'] ?? '');
+ if ($partparameter['unit_id'] !== null && (int) $partparameter['unit_id'] !== 0) {
+ $entity->setUnit($this->getUnitSymbol($data, (int)$partparameter['unit_id']));
+ } else {
+ $entity->setUnit("");
+ }
+
+ if ($partparameter['value'] !== null) {
+ $entity->setValueTypical((float) $partparameter['value'] * $this->getSIPrefixFactor($data, (int) $partparameter['siPrefix_id']));
+ }
+ if ($partparameter['minimumValue'] !== null) {
+ $entity->setValueMin((float) $partparameter['minimumValue'] * $this->getSIPrefixFactor($data, (int) $partparameter['minSiPrefix_id']));
+ }
+ if ($partparameter['maximumValue'] !== null) {
+ $entity->setValueMax((float) $partparameter['maximumValue'] * $this->getSIPrefixFactor($data, (int) $partparameter['maxSiPrefix_id']));
+ }
+
+ $part = $this->em->find(Part::class, (int) $partparameter['part_id']);
+ if (!$part instanceof Part) {
+ throw new \RuntimeException(sprintf('Could not find part with ID %s', $partparameter['part_id']));
+ }
+
+ $part->addParameter($entity);
+ $this->em->persist($entity);
+ }
+ $this->em->flush();
+ }
+
+ /**
+ * Returns the currency for the given ISO code. If the currency does not exist, it is created.
+ * This function returns null if the ISO code is the base currency.
+ */
+ protected function getOrCreateCurrency(string $currency_iso_code): ?Currency
+ {
+ //Normalize ISO code
+ $currency_iso_code = strtoupper($currency_iso_code);
+
+ //We do not have a currency for the base currency to be consistent with prices without currencies
+ if ($currency_iso_code === $this->base_currency) {
+ return null;
+ }
+
+ $currency = $this->em->getRepository(Currency::class)->findOneBy([
+ 'iso_code' => $currency_iso_code,
+ ]);
+
+ if ($currency === null) {
+ $currency = new Currency();
+ $currency->setIsoCode($currency_iso_code);
+ $currency->setName(Currencies::getName($currency_iso_code));
+ $this->em->persist($currency);
+ $this->em->flush();
+ }
+
+ return $currency;
+ }
+
+ protected function importOrderdetails(array $data): void
+ {
+ if (!isset($data['partdistributor'])) {
+ throw new \RuntimeException('$data must contain a "partdistributor" key!');
+ }
+
+ foreach ($data['partdistributor'] as $partdistributor) {
+ //Retrieve the part
+ $part = $this->em->find(Part::class, (int) $partdistributor['part_id']);
+ if (!$part instanceof Part) {
+ throw new \RuntimeException(sprintf('Could not find part with ID %s', $partdistributor['part_id']));
+ }
+ //Retrieve the distributor
+ $supplier = $this->em->find(Supplier::class, (int) $partdistributor['distributor_id']);
+ if (!$supplier instanceof Supplier) {
+ throw new \RuntimeException(sprintf('Could not find supplier with ID %s', $partdistributor['distributor_id']));
+ }
+
+ //Check if the part already has an orderdetail for this supplier and ordernumber
+ if (empty($partdistributor['orderNumber']) && !empty($partdistributor['sku'])) {
+ $spn = $partdistributor['sku'];
+ } elseif (!empty($partdistributor['orderNumber']) && empty($partdistributor['sku'])) {
+ $spn = $partdistributor['orderNumber'];
+ } elseif (!empty($partdistributor['orderNumber']) && !empty($partdistributor['sku'])) {
+ $spn = $partdistributor['orderNumber'] . ' (' . $partdistributor['sku'] . ')';
+ } else {
+ $spn = '';
+ }
+
+ $orderdetail = $this->em->getRepository(Orderdetail::class)->findOneBy([
+ 'part' => $part,
+ 'supplier' => $supplier,
+ 'supplierpartnr' => $spn,
+ ]);
+
+ //When no orderdetail exists, create one
+ if ($orderdetail === null) {
+ $orderdetail = new Orderdetail();
+ $orderdetail->setSupplier($supplier);
+ $orderdetail->setSupplierpartnr($spn);
+ $part->addOrderdetail($orderdetail);
+ $this->em->persist($orderdetail);
+ }
+
+ //Add the price information to the orderdetail (only if the price is not zero, as this was a placeholder in PartKeepr)
+ if (!empty($partdistributor['price']) && !BigDecimal::of($partdistributor['price'])->isZero()) {
+ $pricedetail = new Pricedetail();
+ $orderdetail->addPricedetail($pricedetail);
+ //Partkeepr stores the price per item, we need to convert it to the price per packaging unit
+ $price_per_item = BigDecimal::of($partdistributor['price']);
+ $packaging_unit = (float) ($partdistributor['packagingUnit'] ?? 1);
+ $pricedetail->setPrice($price_per_item->multipliedBy($packaging_unit));
+ $pricedetail->setPriceRelatedQuantity($packaging_unit);
+ //We have to set the minimum discount quantity to the packaging unit (PartKeepr does not know this concept)
+ //But in Part-DB the minimum discount qty have to be unique across a orderdetail
+ $pricedetail->setMinDiscountQuantity($packaging_unit);
+
+ //Set the currency of the price
+ if (!empty($partdistributor['currency'])) {
+ $currency = $this->getOrCreateCurrency($partdistributor['currency']);
+ $pricedetail->setCurrency($currency);
+ }
+
+ $this->em->persist($pricedetail);
+ }
+
+ $this->em->flush();
+ //Clear the entity manager to improve performance
+ $this->em->clear();
+ }
+ }
+
+ /**
+ * Returns the (parameter) unit symbol for the given ID.
+ */
+ protected function getUnitSymbol(array $data, int $id): string
+ {
+ foreach ($data['unit'] as $unit) {
+ if ((int) $unit['id'] === $id) {
+ return $unit['symbol'];
+ }
+ }
+
+ throw new \RuntimeException(sprintf('Could not find unit with ID %s', $id));
+ }
+}
diff --git a/src/Services/InfoProviderSystem/DTOs/FileDTO.php b/src/Services/InfoProviderSystem/DTOs/FileDTO.php
new file mode 100644
index 00000000..0d1db76a
--- /dev/null
+++ b/src/Services/InfoProviderSystem/DTOs/FileDTO.php
@@ -0,0 +1,53 @@
+.
+ */
+
+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.
+ * @see \App\Tests\Services\InfoProviderSystem\DTOs\FileDTOTest
+ */
+class FileDTO
+{
+ /**
+ * @var string The URL where to get this file
+ */
+ public readonly string $url;
+
+ /**
+ * @param string $url The URL where to get this file
+ * @param string|null $name Optionally the name of this file
+ */
+ public function __construct(
+ string $url,
+ public readonly ?string $name = null,
+ ) {
+ //Find all occurrences of non URL safe characters and replace them with their URL encoded version.
+ //We only want to replace characters which can not have a valid meaning in a URL (what would break the URL).
+ //Digikey provided some wrong URLs with a ^ in them, which is not a valid URL character. (https://github.com/Part-DB/Part-DB-server/issues/521)
+ $this->url = preg_replace_callback('/[^a-zA-Z0-9_\-.$+!*();\/?:@=%]/', static fn($matches) => rawurlencode($matches[0]), $url);
+ }
+
+
+}
\ 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..0b54d1a9
--- /dev/null
+++ b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php
@@ -0,0 +1,165 @@
+.
+ */
+
+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.
+ * @see \App\Tests\Services\InfoProviderSystem\DTOs\ParameterDTOTest
+ */
+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 (or 1~2) here, which will be parsed as value_min: 1.0, value_max: 2.0.
+ *
+ * For certain expressions (like ranges) the unit is automatically extracted from the value, if no unit is given
+ * @TODO Rework that, so that the difference between parseValueField and parseValueIncludingUnit is clearer or merge them
+ * @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 we encounter something like 2.5@text, then put the "@text" into text_value and continue with the number parsing
+ if (is_string($value) && preg_match('/^(.+)(@.+)$/', $value, $matches) === 1) {
+ $value = $matches[1];
+ $value_text = $matches[2];
+ } else {
+ $value_text = null;
+ }
+
+ //If the value is just a number, we assume thats the typical value
+ if (is_float($value) || is_numeric($value)) {
+ return new self($name, value_text: $value_text, value_typ: (float) $value, unit: $unit, symbol: $symbol,
+ group: $group);
+ }
+
+ //If the attribute contains ".." or "..." or a tilde we assume it is a range
+ if (preg_match('/(\.{2,3}|~)/', $value) === 1) {
+ $parts = preg_split('/\s*(\.{2,3}|~)\s*/', $value);
+ if (count($parts) === 2) {
+ //Try to extract number and unit from value (allow leading +)
+ if ($unit === null || trim($unit) === '') {
+ [$number, $unit] = self::splitIntoValueAndUnit(ltrim($parts[0], " +")) ?? [$parts[0], null];
+ } else {
+ $number = $parts[0];
+ }
+
+ // If the second part has some extra info, we'll save that into value_text
+ if (!empty($unit) && preg_match('/^(.+' . preg_quote($unit, '/') . ')\s*(.+)$/', $parts[1], $matches) > 0) {
+ $parts[1] = $matches[1];
+ $value_text2 = $matches[2];
+ } else {
+ $value_text2 = null;
+ }
+ [$number2, $unit2] = self::splitIntoValueAndUnit(ltrim($parts[1], " +")) ?? [$parts[1], $unit];
+
+ //If both parts have the same unit and both values are numerical, we'll save it as range
+ if ($unit === $unit2 && is_numeric($number) && is_numeric($number2)) {
+ return new self(name: $name, value_text: $value_text2, value_min: (float) $number,
+ value_max: (float) $number2, unit: $unit, symbol: $symbol, group: $group);
+ }
+ }
+ //If it's a plus/minus value, we'll also treat it as a range
+ } elseif (str_starts_with($value, '±')) {
+ [$number, $unit] = self::splitIntoValueAndUnit(ltrim($value, " ±")) ?? [ltrim($value, ' ±'), $unit];
+ if (is_numeric($number)) {
+ return new self(name: $name, value_min: -abs((float) $number), value_max: abs((float) $number), unit: $unit, symbol: $symbol, group: $group);
+ }
+ }
+
+ //If no unit was passed to us, try to extract it from the value
+ if (empty($unit)) {
+ [$value, $unit] = self::splitIntoValueAndUnit($value) ?? [$value, null];
+ }
+
+ //Were we successful in trying to reduce the value to a number?
+ if ($value_text !== null && is_numeric($value)) {
+ return new self($name, value_text: $value_text, value_typ: (float) $value, unit: $unit, symbol: $symbol,
+ group: $group);
+ }
+
+ return new self($name, value_text: $value.$value_text, 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)) {
+ [$number, $unit] = self::splitIntoValueAndUnit($value) ?? [$value, null];
+
+ return self::parseValueField(name: $name, value: $number, 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);
+ }
+
+ /**
+ * Splits the given value into a value and a unit part if possible.
+ * If the value is not in the expected format, null is returned.
+ * @param string $value The value to split
+ * @return array|null An array with the value and the unit part or null if the value is not in the expected format
+ * @phpstan-return array{0: string, 1: string}|null
+ */
+ public static function splitIntoValueAndUnit(string $value): ?array
+ {
+ if (preg_match('/^(?-?[0-9\.]+)\s*(?[%Ωµ°℃a-z_\/]+\s?\w{0,4})$/iu', $value, $matches)) {
+ $value = $matches['value'];
+ $unit = $matches['unit'];
+
+ return [$value, $unit];
+ }
+
+ return null;
+ }
+}
diff --git a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php
new file mode 100644
index 00000000..9f365f1e
--- /dev/null
+++ b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php
@@ -0,0 +1,73 @@
+.
+ */
+
+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,
+ /** The URL to the product on the website of the manufacturer */
+ public readonly ?string $manufacturer_product_url = 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..f1eb28f7
--- /dev/null
+++ b/src/Services/InfoProviderSystem/DTOs/PriceDTO.php
@@ -0,0 +1,59 @@
+.
+ */
+
+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,
+ /** @var float the price related quantity */
+ public readonly ?float $price_related_quantity = 1.0,
+ )
+ {
+ $this->price_as_big_decimal = BigDecimal::of($this->price);
+ }
+
+ /**
+ * Gets the price as BigDecimal
+ * @return BigDecimal
+ */
+ public function getPriceAsBigDecimal(): BigDecimal
+ {
+ return $this->price_as_big_decimal;
+ }
+}
diff --git a/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php
new file mode 100644
index 00000000..bcd8be43
--- /dev/null
+++ b/src/Services/InfoProviderSystem/DTOs/PurchaseInfoDTO.php
@@ -0,0 +1,48 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\InfoProviderSystem\DTOs;
+
+/**
+ * This DTO represents a purchase information for a part (supplier name, order number and prices).
+ * @see \App\Tests\Services\InfoProviderSystem\DTOs\PurchaseInfoDTOTest
+ */
+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..28943702
--- /dev/null
+++ b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php
@@ -0,0 +1,74 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\InfoProviderSystem\DTOs;
+
+use App\Entity\Parts\ManufacturingStatus;
+
+/**
+ * This DTO represents a search result for a part.
+ * @see \App\Tests\Services\InfoProviderSystem\DTOs\SearchResultDTOTest
+ */
+class SearchResultDTO
+{
+ /** @var string|null An URL to a preview image */
+ public readonly ?string $preview_image_url;
+ /** @var FileDTO|null The preview image as FileDTO object */
+ public readonly ?FileDTO $preview_image_file;
+
+ 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 */
+ ?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,
+ ) {
+
+ if ($preview_image_url !== null) {
+ //Utilize the escaping mechanism of FileDTO to ensure that the preview image URL is correctly encoded
+ //See issue #521: https://github.com/Part-DB/Part-DB-server/issues/521
+ $this->preview_image_file = new FileDTO($preview_image_url);
+ $this->preview_image_url = $this->preview_image_file->url;
+ } else {
+ $this->preview_image_file = null;
+ $this->preview_image_url = 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..40f69498
--- /dev/null
+++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php
@@ -0,0 +1,356 @@
+.
+ */
+
+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\PartParameter;
+use App\Entity\Parts\Category;
+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\Repository\Parts\CategoryRepository;
+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 Doctrine\ORM\EntityManagerInterface;
+
+/**
+ * This class converts DTOs to entities which can be persisted in the DB
+ * @see \App\Tests\Services\InfoProviderSystem\DTOtoEntityConverterTest
+ */
+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());
+ $entity->setPriceRelatedQuantity($dto->price_related_quantity);
+
+ //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 ($dto->name === null || $dto->name === '' || $dto->name === '0') {
+ $entity->setName($this->getAttachmentNameFromURL($dto->url));
+ } else {
+ $entity->setName($dto->name);
+ }
+
+ return $entity;
+ }
+
+ private function getAttachmentNameFromURL(string $url): string
+ {
+ return basename(parse_url($url, PHP_URL_PATH));
+ }
+
+ /**
+ * 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);
+
+ //Try to map the category to an existing entity (but never create a new one)
+ if ($dto->category) {
+ //@phpstan-ignore-next-line For some reason php does not recognize the repo returns a category
+ $entity->setCategory($this->em->getRepository(Category::class)->findForInfoProvider($dto->category));
+ }
+
+ $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);
+ $entity->setManufacturerProductURL($dto->manufacturer_product_url ?? '');
+
+ //Set the provider reference on the part
+ $entity->setProviderReference(InfoProviderReference::fromPartDTO($dto));
+
+ $param_groups = [];
+
+ //Add parameters
+ foreach ($dto->parameters ?? [] as $parameter) {
+ $new_param = $this->convertParameter($parameter);
+
+ $key = $new_param->getName() . '##' . $new_param->getGroup();
+ //If there is already an parameter with the same name and group, rename the new parameter, by suffixing a number
+ if (count($param_groups[$key] ?? []) > 0) {
+ $new_param->setName($new_param->getName() . ' (' . (count($param_groups[$key]) + 1) . ')');
+ }
+
+ $param_groups[$key][] = $new_param;
+
+ $entity->addParameter($new_param);
+ }
+
+ //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);
+ }
+
+ $attachments_grouped = [];
+
+ //Add other images
+ $images = $this->files_unique($dto->images ?? []);
+ foreach ($images as $image) {
+ //Ensure that the image is not the same as the preview image
+ if ($image->url === $dto->preview_image_url) {
+ continue;
+ }
+
+ $attachment = $this->convertFile($image, $image_type);
+
+ $attachments_grouped[$attachment->getName()][] = $attachment;
+ if (count($attachments_grouped[$attachment->getName()] ?? []) > 1) {
+ $attachment->setName($attachment->getName() . ' (' . (count($attachments_grouped[$attachment->getName()]) + 1) . ')');
+ }
+
+
+ $entity->addAttachment($attachment);
+ }
+
+ //Add datasheets
+ $datasheet_type = $this->getDatasheetType();
+ $datasheets = $this->files_unique($dto->datasheets ?? []);
+ foreach ($datasheets as $datasheet) {
+ $attachment = $this->convertFile($datasheet, $datasheet_type);
+
+ $attachments_grouped[$attachment->getName()][] = $attachment;
+ if (count($attachments_grouped[$attachment->getName()] ?? []) > 1) {
+ $attachment->setName($attachment->getName() . ' (' . (count($attachments_grouped[$attachment->getName()])) . ')');
+ }
+
+ $entity->addAttachment($attachment);
+ }
+
+ //Add orderdetails and prices
+ foreach ($dto->vendor_infos ?? [] as $vendor_info) {
+ $entity->addOrderdetail($this->convertPurchaseInfo($vendor_info));
+ }
+
+ return $entity;
+ }
+
+ /**
+ * Returns the given array of files with all duplicates removed.
+ * @param FileDTO[] $files
+ * @return FileDTO[]
+ */
+ private function files_unique(array $files): array
+ {
+ $unique = [];
+ //We use the URL and name as unique identifier. If two file DTO have the same URL and name, they are considered equal
+ //and get filtered out, if it already exists in the array
+ foreach ($files as $file) {
+ //Skip already existing files, to preserve the order. The second condition ensure that we keep the version with a name over the one without a name
+ if (isset($unique[$file->url]) && $unique[$file->url]->name !== null) {
+ continue;
+ }
+ $unique[$file->url] = $file;
+ }
+
+ return array_values($unique);
+ }
+
+ /**
+ * 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_IMAGE_NAME);
+ }
+
+ return $tmp;
+ }
+
+}
\ No newline at end of file
diff --git a/src/Services/InfoProviderSystem/ExistingPartFinder.php b/src/Services/InfoProviderSystem/ExistingPartFinder.php
new file mode 100644
index 00000000..762c1517
--- /dev/null
+++ b/src/Services/InfoProviderSystem/ExistingPartFinder.php
@@ -0,0 +1,77 @@
+findAllExisting($dto);
+ return count($results) > 0 ? $results[0] : null;
+ }
+
+ /**
+ * Returns all existing local parts that match the search result.
+ * If no part is found, return an empty array.
+ * @param SearchResultDTO $dto
+ * @return Part[]
+ */
+ public function findAllExisting(SearchResultDTO $dto): array
+ {
+ $qb = $this->em->getRepository(Part::class)->createQueryBuilder('part');
+ $qb->select('part')
+ ->leftJoin('part.manufacturer', 'manufacturer')
+ ->Orwhere($qb->expr()->andX(
+ 'part.providerReference.provider_key = :providerKey',
+ 'part.providerReference.provider_id = :providerId',
+ ))
+
+ //Or the manufacturer (allowing for alternative names) and the MPN (or part name) must match
+ ->OrWhere(
+ $qb->expr()->andX(
+ $qb->expr()->orX(
+ "ILIKE(manufacturer.name, :manufacturerName) = TRUE",
+ "ILIKE(manufacturer.alternative_names, :manufacturerAltNames) = TRUE",
+ ),
+ $qb->expr()->orX(
+ "ILIKE(part.manufacturer_product_number, :mpn) = TRUE",
+ "ILIKE(part.name, :mpn) = TRUE",
+ )
+ )
+ )
+ ;
+
+ $qb->setParameter('providerKey', $dto->provider_key);
+ $qb->setParameter('providerId', $dto->provider_id);
+
+ $qb->setParameter('manufacturerName', $dto->manufacturer);
+ $qb->setParameter('manufacturerAltNames', '%'.$dto->manufacturer.'%');
+ $qb->setParameter('mpn', $dto->mpn);
+
+ return $qb->getQuery()->getResult();
+ }
+}
\ 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..0eb74642
--- /dev/null
+++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php
@@ -0,0 +1,148 @@
+.
+ */
+
+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\Component\DependencyInjection\Attribute\Autowire;
+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 * 4; // 7 days
+
+ public function __construct(private readonly ProviderRegistry $provider_registry,
+ private readonly DTOtoEntityConverter $dto_to_entity_converter, private readonly CacheInterface $partInfoCache,
+ #[Autowire(param: "kernel.debug")]
+ private readonly bool $debugMode = false)
+ {
+ }
+
+ /**
+ * 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);
+ }
+
+ //Ensure that the provider is active
+ if (!$provider->isActive()) {
+ throw new \RuntimeException("The provider with key {$provider->getProviderKey()} is not active!");
+ }
+
+ 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
+ {
+ //Generate key and escape reserved characters from the provider id
+ $escaped_keyword = urlencode($keyword);
+ return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
+ //Set the expiration time
+ $item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 1);
+
+ 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);
+
+ //Ensure that the provider is active
+ if (!$provider->isActive()) {
+ throw new \RuntimeException("The provider with key $provider_key is not active!");
+ }
+
+ //Generate key and escape reserved characters from the provider id
+ $escaped_part_id = urlencode($part_id);
+ return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) {
+ //Set the expiration time
+ $item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 1);
+
+ 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..f6c398d2
--- /dev/null
+++ b/src/Services/InfoProviderSystem/ProviderRegistry.php
@@ -0,0 +1,142 @@
+.
+ */
+
+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
+ * @see \App\Tests\Services\InfoProviderSystem\ProviderRegistryTest
+ */
+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 = [];
+
+ /**
+ * @var bool Whether the registry has been initialized
+ */
+ private bool $initialized = false;
+
+ /**
+ * @param iterable $providers
+ */
+ public function __construct(private readonly iterable $providers)
+ {
+ //We do not initialize the structures here, because we do not want to do unnecessary work
+ //We do this lazy on the first call to getProviders()
+ }
+
+ /**
+ * Initializes the registry, we do this lazy to avoid unnecessary work, on construction, which is always called
+ * even if the registry is not used
+ * @return void
+ */
+ private function initStructures(): void
+ {
+ foreach ($this->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;
+ }
+ }
+
+ $this->initialized = true;
+ }
+
+ /**
+ * Returns an array of all registered providers (enabled and disabled)
+ * @return InfoProviderInterface[]
+ */
+ public function getProviders(): array
+ {
+ if (!$this->initialized) {
+ $this->initStructures();
+ }
+
+ 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
+ {
+ if (!$this->initialized) {
+ $this->initStructures();
+ }
+
+ 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
+ {
+ if (!$this->initialized) {
+ $this->initStructures();
+ }
+
+ return $this->providers_active;
+ }
+
+ /**
+ * Returns an array of all disabled providers
+ * @return InfoProviderInterface[]
+ */
+ public function getDisabledProviders(): array
+ {
+ if (!$this->initialized) {
+ $this->initStructures();
+ }
+
+ 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..b20368ce
--- /dev/null
+++ b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php
@@ -0,0 +1,314 @@
+.
+ */
+
+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;
+
+ /**
+ * A list of parameter IDs, that are always assumed as text only and will never be converted to a numerical value.
+ * This allows to fix issues like #682, where the "Supplier Device Package" was parsed as a numerical value.
+ */
+ private const TEXT_ONLY_PARAMETERS = [
+ 1291, //Supplier Device Package
+ 39246, //Package / Case
+ ];
+
+ 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 $this->clientId !== '' && $this->authTokenManager->hasToken(self::OAUTH_APP_NAME);
+ }
+
+ public function searchByKeyword(string $keyword): array
+ {
+ $request = [
+ 'Keywords' => $keyword,
+ 'Limit' => 50,
+ 'Offset' => 0,
+ 'FilterOptionsRequest' => [
+ 'MarketPlaceFilter' => 'ExcludeMarketPlace',
+ ],
+ ];
+
+ //$response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [
+ $response = $this->digikeyClient->request('POST', '/products/v4/search/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) {
+ foreach ($product['ProductVariations'] as $variation) {
+ $result[] = new SearchResultDTO(
+ provider_key: $this->getProviderKey(),
+ provider_id: $variation['DigiKeyProductNumber'],
+ name: $product['ManufacturerProductNumber'],
+ description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'],
+ category: $this->getCategoryString($product),
+ manufacturer: $product['Manufacturer']['Name'] ?? null,
+ mpn: $product['ManufacturerProductNumber'],
+ preview_image_url: $product['PhotoUrl'] ?? null,
+ manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']['Id']),
+ provider_url: $product['ProductUrl'],
+ footprint: $variation['PackageType']['Name'], //Use the footprint field, to show the user the package type (Tape & Reel, etc., as digikey has many different package types)
+ );
+ }
+ }
+
+ return $result;
+ }
+
+ public function getDetails(string $id): PartDetailDTO
+ {
+ $response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/productdetails', [
+ 'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
+ ]);
+
+ $response_array = $response->toArray();
+ $product = $response_array['Product'];
+
+ $footprint = null;
+ $parameters = $this->parametersToDTOs($product['Parameters'] ?? [], $footprint);
+ $media = $this->mediaToDTOs($id);
+
+ // Get the price_breaks of the selected variation
+ $price_breaks = [];
+ foreach ($product['ProductVariations'] as $variation) {
+ if ($variation['DigiKeyProductNumber'] == $id) {
+ $price_breaks = $variation['StandardPricing'] ?? [];
+ break;
+ }
+ }
+
+ return new PartDetailDTO(
+ provider_key: $this->getProviderKey(),
+ provider_id: $id,
+ name: $product['ManufacturerProductNumber'],
+ description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'],
+ category: $this->getCategoryString($product),
+ manufacturer: $product['Manufacturer']['Name'] ?? null,
+ mpn: $product['ManufacturerProductNumber'],
+ preview_image_url: $product['PhotoUrl'] ?? null,
+ manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']['Id']),
+ provider_url: $product['ProductUrl'],
+ footprint: $footprint,
+ datasheets: $media['datasheets'],
+ images: $media['images'],
+ parameters: $parameters,
+ vendor_infos: $this->pricingToDTOs($price_breaks, $id, $product['ProductUrl']),
+ );
+ }
+
+ /**
+ * Converts the product status from the Digikey API to the manufacturing status used in Part-DB
+ * @param int|null $dk_status
+ * @return ManufacturingStatus|null
+ */
+ private function productStatusToManufacturingStatus(?int $dk_status): ?ManufacturingStatus
+ {
+ // The V4 can use strings to get the status, but if you have changed the PROVIDER_DIGIKEY_LANGUAGE it will not match.
+ // Using the Id instead which should be fixed.
+ //
+ // The API is not well documented and the ID are not there yet, so were extracted using "trial and error".
+ // The 'Preliminary' id was not found in several categories so I was unable to extract it. Disabled for now.
+ return match ($dk_status) {
+ null => null,
+ 0 => ManufacturingStatus::ACTIVE,
+ 1 => ManufacturingStatus::DISCONTINUED,
+ 2, 4 => ManufacturingStatus::EOL,
+ 7 => ManufacturingStatus::NRFND,
+ //'Preliminary' => ManufacturingStatus::ANNOUNCED,
+ default => ManufacturingStatus::NOT_SET,
+ };
+ }
+
+ private function getCategoryString(array $product): string
+ {
+ $category = $product['Category']['Name'];
+ $sub_category = current($product['Category']['ChildCategories']);
+
+ if ($sub_category) {
+ //Replace the ' - ' category separator with ' -> '
+ $category = $category . ' -> ' . str_replace(' - ', ' -> ', $sub_category["Name"]);
+ }
+
+ return $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['ValueText'];
+ }
+
+ if (in_array(trim((string) $parameter['ValueText']), ['', '-'], true)) {
+ continue;
+ }
+
+ //If the parameter was marked as text only, then we do not try to parse it as a numerical value
+ if (in_array($parameter['ParameterId'], self::TEXT_ONLY_PARAMETERS, true)) {
+ $results[] = new ParameterDTO(name: $parameter['ParameterText'], value_text: $parameter['ValueText']);
+ } else { //Otherwise try to parse it as a numerical value
+ $results[] = ParameterDTO::parseValueIncludingUnit($parameter['ParameterText'], $parameter['ValueText']);
+ }
+ }
+
+ 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 string $id The Digikey product number, to get the media for
+ * @return FileDTO[][]
+ * @phpstan-return array
+ */
+ private function mediaToDTOs(string $id): array
+ {
+ $datasheets = [];
+ $images = [];
+
+ $response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/media', [
+ 'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
+ ]);
+
+ $media_array = $response->toArray();
+
+ foreach ($media_array['MediaLinks'] 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,
+ ];
+ }
+
+}
diff --git a/src/Services/InfoProviderSystem/Providers/Element14Provider.php b/src/Services/InfoProviderSystem/Providers/Element14Provider.php
new file mode 100644
index 00000000..b942b929
--- /dev/null
+++ b/src/Services/InfoProviderSystem/Providers/Element14Provider.php
@@ -0,0 +1,310 @@
+.
+ */
+
+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 Composer\CaBundle\CaBundle;
+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.4';
+ 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'];
+
+ private readonly HttpClientInterface $element14Client;
+
+ public function __construct(HttpClientInterface $element14Client, private readonly string $api_key, private readonly string $store_id)
+ {
+ /* We use the mozilla CA from the composer ca bundle directly, as some debian systems seems to have problems
+ * with the SSL.COM CA, element14 uses. See https://github.com/Part-DB/Part-DB-server/issues/866
+ *
+ * This is a workaround until the issue is resolved in debian (or never).
+ * As this only affects this provider, this should have no negative impact and the CA bundle is still secure.
+ */
+ $this->element14Client = $element14Client->withOptions([
+ 'cafile' => CaBundle::getBundledCaBundlePath(),
+ ]);
+ }
+
+ 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 $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',
+ 'versionNumber' => 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: $product['productURL'],
+ notes: $product['productOverview']['description'] ?? null,
+ datasheets: $this->parseDataSheets($product['datasheets'] ?? null),
+ parameters: $this->attributesToParameters($product['attributes'] ?? null),
+ vendor_infos: $this->pricesToVendorInfo($product['sku'], $product['prices'] ?? [], $product['productURL']),
+
+ );
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param array|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, string $product_url): 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: $product_url
+ )
+ ];
+ }
+
+ public function getUsedCurrency(): string
+ {
+ //Decide based on the shop ID
+ return match ($this->store_id) {
+ 'bg.farnell.com', 'at.farnell.com', 'si.farnell.com', 'sk.farnell.com', 'ro.farnell.com', 'pt.farnell.com', 'nl.farnell.com', 'be.farnell.com', 'lv.farnell.com', 'lt.farnell.com', 'it.farnell.com', 'fr.farnell.com', 'fi.farnell.com', 'ee.farnell.com', 'es.farnell.com', 'ie.farnell.com', 'cpcireland.farnell.com', 'de.farnell.com' => 'EUR',
+ 'cz.farnell.com' => 'CZK',
+ 'dk.farnell.com' => 'DKK',
+ 'ch.farnell.com' => 'CHF',
+ 'cpc.farnell.com', 'uk.farnell.com', 'onecall.farnell.com', 'export.farnell.com' => 'GBP',
+ 'il.farnell.com', 'www.newark.com' => 'USD',
+ 'hu.farnell.com' => 'HUF',
+ 'no.farnell.com' => 'NOK',
+ 'pl.farnell.com' => 'PLN',
+ 'ru.farnell.com' => 'RUB',
+ 'se.farnell.com' => 'SEK',
+ 'tr.farnell.com' => 'TRY',
+ 'canada.newark.com' => 'CAD',
+ 'mexico.newark.com' => 'MXN',
+ '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/LCSCProvider.php b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php
new file mode 100755
index 00000000..d903a8dd
--- /dev/null
+++ b/src/Services/InfoProviderSystem/Providers/LCSCProvider.php
@@ -0,0 +1,366 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\InfoProviderSystem\Providers;
+
+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 Symfony\Component\HttpFoundation\Cookie;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+
+class LCSCProvider implements InfoProviderInterface
+{
+
+ private const ENDPOINT_URL = 'https://wmsc.lcsc.com/ftps/wm';
+
+ public const DISTRIBUTOR_NAME = 'LCSC';
+
+ public function __construct(private readonly HttpClientInterface $lcscClient, private readonly string $currency, private readonly bool $enabled = true)
+ {
+
+ }
+
+ public function getProviderInfo(): array
+ {
+ return [
+ 'name' => 'LCSC',
+ 'description' => 'This provider uses the (unofficial) LCSC API to search for parts.',
+ 'url' => 'https://www.lcsc.com/',
+ 'disabled_help' => 'Set PROVIDER_LCSC_ENABLED to 1 (or true) in your environment variable config.'
+ ];
+ }
+
+ public function getProviderKey(): string
+ {
+ return 'lcsc';
+ }
+
+ // This provider is always active
+ public function isActive(): bool
+ {
+ return $this->enabled;
+ }
+
+ /**
+ * @param string $id
+ * @return PartDetailDTO
+ */
+ private function queryDetail(string $id): PartDetailDTO
+ {
+ $response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/product/detail", [
+ 'headers' => [
+ 'Cookie' => new Cookie('currencyCode', $this->currency)
+ ],
+ 'query' => [
+ 'productCode' => $id,
+ ],
+ ]);
+
+ $arr = $response->toArray();
+ $product = $arr['result'] ?? null;
+
+ if ($product === null) {
+ throw new \RuntimeException('Could not find product code: ' . $id);
+ }
+
+ return $this->getPartDetail($product);
+ }
+
+ /**
+ * @param string $url
+ * @return String
+ */
+ private function getRealDatasheetUrl(?string $url): string
+ {
+ if ($url !== null && trim($url) !== '' && preg_match("/^https:\/\/(datasheet\.lcsc\.com|www\.lcsc\.com\/datasheet)\/.*(C\d+)\.pdf$/", $url, $matches) > 0) {
+ if (preg_match("/^https:\/\/datasheet\.lcsc\.com\/lcsc\/(.*\.pdf)$/", $url, $rewriteMatches) > 0) {
+ $url = 'https://www.lcsc.com/datasheet/lcsc_datasheet_' . $rewriteMatches[1];
+ }
+ $response = $this->lcscClient->request('GET', $url, [
+ 'headers' => [
+ 'Referer' => 'https://www.lcsc.com/product-detail/_' . $matches[2] . '.html'
+ ],
+ ]);
+ if (preg_match('/(previewPdfUrl): ?("[^"]+wmsc\.lcsc\.com[^"]+\.pdf")/', $response->getContent(), $matches) > 0) {
+ //HACKY: The URL string contains escaped characters like \u002F, etc. To decode it, the JSON decoding is reused
+ //See https://github.com/Part-DB/Part-DB-server/pull/582#issuecomment-2033125934
+ $jsonObj = json_decode('{"' . $matches[1] . '": ' . $matches[2] . '}');
+ $url = $jsonObj->previewPdfUrl;
+ }
+ }
+ return $url;
+ }
+
+ /**
+ * @param string $term
+ * @return PartDetailDTO[]
+ */
+ private function queryByTerm(string $term): array
+ {
+ $response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/search/global", [
+ 'headers' => [
+ 'Cookie' => new Cookie('currencyCode', $this->currency)
+ ],
+ 'query' => [
+ 'keyword' => $term,
+ ],
+ ]);
+
+ $arr = $response->toArray();
+
+ // Get products list
+ $products = $arr['result']['productSearchResultVO']['productList'] ?? [];
+ // Get product tip
+ $tipProductCode = $arr['result']['tipProductDetailUrlVO']['productCode'] ?? null;
+
+ $result = [];
+
+ // LCSC does not display LCSC codes in the search, instead taking you directly to the
+ // detailed product listing. It does so utilizing a product tip field.
+ // If product tip exists and there are no products in the product list try a detail query
+ if (count($products) === 0 && $tipProductCode !== null) {
+ $result[] = $this->queryDetail($tipProductCode);
+ }
+
+ foreach ($products as $product) {
+ $result[] = $this->getPartDetail($product);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Sanitizes a field by removing any HTML tags and other unwanted characters
+ * @param string|null $field
+ * @return string|null
+ */
+ private function sanitizeField(?string $field): ?string
+ {
+ if ($field === null) {
+ return null;
+ }
+
+ return strip_tags($field);
+ }
+
+
+ /**
+ * Takes a deserialized json object of the product and returns a PartDetailDTO
+ * @param array $product
+ * @return PartDetailDTO
+ */
+ private function getPartDetail(array $product): PartDetailDTO
+ {
+ // Get product images in advance
+ $product_images = $this->getProductImages($product['productImages'] ?? null);
+ $product['productImageUrl'] ??= null;
+
+ // If the product does not have a product image but otherwise has attached images, use the first one.
+ if (count($product_images) > 0) {
+ $product['productImageUrl'] ??= $product_images[0]->url;
+ }
+
+ // LCSC puts HTML in footprints and descriptions sometimes randomly
+ $footprint = $product["encapStandard"] ?? null;
+ //If the footprint just consists of a dash, we'll assume it's empty
+ if ($footprint === '-') {
+ $footprint = null;
+ }
+
+ //Build category by concatenating the catalogName and parentCatalogName
+ $category = $product['parentCatalogName'] ?? null;
+ if (isset($product['catalogName'])) {
+ $category = ($category ?? '') . ' -> ' . $product['catalogName'];
+
+ // Replace the / with a -> for better readability
+ $category = str_replace('/', ' -> ', $category);
+ }
+
+ return new PartDetailDTO(
+ provider_key: $this->getProviderKey(),
+ provider_id: $product['productCode'],
+ name: $product['productModel'],
+ description: $this->sanitizeField($product['productIntroEn']),
+ category: $this->sanitizeField($category ?? null),
+ manufacturer: $this->sanitizeField($product['brandNameEn'] ?? null),
+ mpn: $this->sanitizeField($product['productModel'] ?? null),
+ preview_image_url: $product['productImageUrl'],
+ manufacturing_status: null,
+ provider_url: $this->getProductShortURL($product['productCode']),
+ footprint: $this->sanitizeField($footprint),
+ datasheets: $this->getProductDatasheets($product['pdfUrl'] ?? null),
+ images: $product_images,
+ parameters: $this->attributesToParameters($product['paramVOList'] ?? []),
+ vendor_infos: $this->pricesToVendorInfo($product['productCode'], $this->getProductShortURL($product['productCode']), $product['productPriceList'] ?? []),
+ mass: $product['weight'] ?? null,
+ );
+ }
+
+ /**
+ * Converts the price array to a VendorInfoDTO array to be used in the PartDetailDTO
+ * @param string $sku
+ * @param string $url
+ * @param array $prices
+ * @return array
+ */
+ private function pricesToVendorInfo(string $sku, string $url, array $prices): array
+ {
+ $price_dtos = [];
+
+ foreach ($prices as $price) {
+ $price_dtos[] = new PriceDTO(
+ minimum_discount_amount: $price['ladder'],
+ price: $price['productPrice'],
+ currency_iso_code: $this->getUsedCurrency($price['currencySymbol']),
+ includes_tax: false,
+ );
+ }
+
+ return [
+ new PurchaseInfoDTO(
+ distributor_name: self::DISTRIBUTOR_NAME,
+ order_number: $sku,
+ prices: $price_dtos,
+ product_url: $url,
+ )
+ ];
+ }
+
+ /**
+ * Converts LCSC currency symbol to an ISO code.
+ * @param string $currency
+ * @return string
+ */
+ private function getUsedCurrency(string $currency): string
+ {
+ //Decide based on the currency symbol
+ return match ($currency) {
+ 'US$', '$' => 'USD',
+ '€' => 'EUR',
+ 'A$' => 'AUD',
+ 'C$' => 'CAD',
+ '£' => 'GBP',
+ 'HK$' => 'HKD',
+ 'JP¥' => 'JPY',
+ 'RM' => 'MYR',
+ 'S$' => 'SGD',
+ '₽' => 'RUB',
+ 'kr' => 'SEK',
+ 'kr.' => 'DKK',
+ '₹' => 'INR',
+ //Fallback to the configured currency
+ default => $this->currency,
+ };
+ }
+
+ /**
+ * Returns a valid LCSC product short URL from product code
+ * @param string $product_code
+ * @return string
+ */
+ private function getProductShortURL(string $product_code): string
+ {
+ return 'https://www.lcsc.com/product-detail/' . $product_code .'.html';
+ }
+
+ /**
+ * Returns a product datasheet FileDTO array from a single pdf url
+ * @param string $url
+ * @return FileDTO[]
+ */
+ private function getProductDatasheets(?string $url): array
+ {
+ if ($url === null) {
+ return [];
+ }
+
+ $realUrl = $this->getRealDatasheetUrl($url);
+
+ return [new FileDTO($realUrl, null)];
+ }
+
+ /**
+ * Returns a FileDTO array with a list of product images
+ * @param array|null $images
+ * @return FileDTO[]
+ */
+ private function getProductImages(?array $images): array
+ {
+ return array_map(static fn($image) => new FileDTO($image), $images ?? []);
+ }
+
+ /**
+ * @param array|null $attributes
+ * @return ParameterDTO[]
+ */
+ private function attributesToParameters(?array $attributes): array
+ {
+ $result = [];
+
+ foreach ($attributes as $attribute) {
+
+ //Skip this attribute if it's empty
+ if (in_array(trim((string) $attribute['paramValueEn']), ['', '-'], true)) {
+ continue;
+ }
+
+ $result[] = ParameterDTO::parseValueIncludingUnit(name: $attribute['paramNameEn'], value: $attribute['paramValueEn'], group: null);
+ }
+
+ return $result;
+ }
+
+ public function searchByKeyword(string $keyword): array
+ {
+ return $this->queryByTerm($keyword);
+ }
+
+ public function getDetails(string $id): PartDetailDTO
+ {
+ $tmp = $this->queryByTerm($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,
+ ProviderCapabilities::PRICE,
+ ProviderCapabilities::FOOTPRINT,
+ ];
+ }
+}
diff --git a/src/Services/InfoProviderSystem/Providers/MouserProvider.php b/src/Services/InfoProviderSystem/Providers/MouserProvider.php
new file mode 100644
index 00000000..90bad263
--- /dev/null
+++ b/src/Services/InfoProviderSystem/Providers/MouserProvider.php
@@ -0,0 +1,350 @@
+.
+ */
+
+/*
+* This file provide an interface with the Mouser API V2 (also compatible with the V1)
+*
+* Copyright (C) 2023 Pasquale D'Orsi (https://github.com/pdo59)
+*
+* TODO: Obtain an API keys with an US Mouser user (currency $) and test the result of prices
+*
+*/
+
+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\PartDetailDTO;
+use App\Services\InfoProviderSystem\DTOs\PriceDTO;
+use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+
+
+class MouserProvider implements InfoProviderInterface
+{
+
+ private const ENDPOINT_URL = 'https://api.mouser.com/api/v2/search';
+
+ public const DISTRIBUTOR_NAME = 'Mouser';
+
+ public function __construct(
+ private readonly HttpClientInterface $mouserClient,
+ private readonly string $api_key,
+ private readonly string $language,
+ private readonly string $options,
+ private readonly int $search_limit
+ ) {
+ }
+
+ public function getProviderInfo(): array
+ {
+ return [
+ 'name' => 'Mouser',
+ 'description' => 'This provider uses the Mouser API to search for parts.',
+ 'url' => 'https://www.mouser.com/',
+ 'disabled_help' => 'Configure the API key in the PROVIDER_MOUSER_KEY environment variable to enable.'
+ ];
+ }
+
+ public function getProviderKey(): string
+ {
+ return 'mouser';
+ }
+
+ public function isActive(): bool
+ {
+ return $this->api_key !== '';
+ }
+
+ public function searchByKeyword(string $keyword): array
+ {
+ /*
+ SearchByKeywordRequest description:
+ Search parts by keyword and return a maximum of 50 parts.
+
+ keyword* string
+ Used for keyword part search.
+
+ records integer($int32)
+ Used to specify how many records the method should return.
+
+ startingRecord integer($int32)
+ Indicates where in the total recordset the return set should begin.
+ From the startingRecord, the number of records specified will be returned up to the end of the recordset.
+ This is useful for paging through the complete recordset of parts matching keyword.
+
+
+ searchOptions string
+ Optional.
+ If not provided, the default is None.
+ Refers to options supported by the search engine.
+ Only one value at a time is supported.
+ Available options: None | Rohs | InStock | RohsAndInStock - can use string representations or integer IDs: 1[None] | 2[Rohs] | 4[InStock] | 8[RohsAndInStock].
+
+ searchWithYourSignUpLanguage string
+ Optional.
+ If not provided, the default is false.
+ Used when searching for keywords in the language specified when you signed up for Search API.
+ Can use string representation: true.
+ {
+ "SearchByKeywordRequest": {
+ "keyword": "BC557",
+ "records": 0,
+ "startingRecord": 0,
+ "searchOptions": "",
+ "searchWithYourSignUpLanguage": ""
+ }
+ }
+ */
+
+ $response = $this->mouserClient->request('POST', self::ENDPOINT_URL."/keyword", [
+ 'query' => [
+ 'apiKey' => $this->api_key,
+ ],
+ 'json' => [
+ 'SearchByKeywordRequest' => [
+ 'keyword' => $keyword,
+ 'records' => $this->search_limit, //self::NUMBER_OF_RESULTS,
+ 'startingRecord' => 0,
+ 'searchOptions' => $this->options,
+ 'searchWithYourSignUpLanguage' => $this->language,
+ ]
+ ],
+ ]);
+
+ return $this->responseToDTOArray($response);
+ }
+
+ public function getDetails(string $id): PartDetailDTO
+ {
+ /*
+ SearchByPartRequest description:
+ Search parts by part number and return a maximum of 50 parts.
+
+ mouserPartNumber string
+ Used to search parts by the specific Mouser part number with a maximum input of 10 part numbers, separated by a pipe symbol for the search.
+ Each part number must be a minimum of 3 characters and a maximum of 40 characters. For example: 494-JANTX2N2222A|610-2N2222-TL|637-2N2222A
+
+ partSearchOptions string
+ Optional.
+ If not provided, the default is None. Refers to options supported by the search engine. Only one value at a time is supported.
+ The following values are valid: None | Exact - can use string representations or integer IDs: 1[None] | 2[Exact]
+
+ {
+ "SearchByPartRequest": {
+ "mouserPartNumber": "string",
+ "partSearchOptions": "string"
+ }
+ }
+ */
+
+ $response = $this->mouserClient->request('POST', self::ENDPOINT_URL."/partnumber", [
+ 'query' => [
+ 'apiKey' => $this->api_key,
+ ],
+ 'json' => [
+ 'SearchByPartRequest' => [
+ 'mouserPartNumber' => $id,
+ 'partSearchOptions' => 2
+ ]
+ ],
+ ]);
+ $tmp = $this->responseToDTOArray($response);
+
+ //Ensure that we have exactly one result
+ if (count($tmp) === 0) {
+ throw new \RuntimeException('No part found with ID '.$id);
+ }
+
+ //Manually filter out the part with the correct ID
+ $tmp = array_filter($tmp, fn(PartDetailDTO $part) => $part->provider_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 reset($tmp);
+ }
+
+ public function getCapabilities(): array
+ {
+ return [
+ ProviderCapabilities::BASIC,
+ ProviderCapabilities::PICTURE,
+ ProviderCapabilities::DATASHEET,
+ ProviderCapabilities::PRICE,
+ ];
+ }
+
+
+ /**
+ * @param ResponseInterface $response
+ * @return PartDetailDTO[]
+ */
+ private function responseToDTOArray(ResponseInterface $response): array
+ {
+ $arr = $response->toArray();
+
+ if (isset($arr['SearchResults'])) {
+ $products = $arr['SearchResults']['Parts'] ?? [];
+ } else {
+ throw new \RuntimeException('Unknown response format: ' .json_encode($arr, JSON_THROW_ON_ERROR));
+ }
+
+ $result = [];
+ foreach ($products as $product) {
+
+ //Check if we have a valid product number. We assume that a product number, must have at least 4 characters
+ //Otherwise filter it out
+ if (strlen($product['MouserPartNumber']) < 4) {
+ continue;
+ }
+
+ //Check if we have a mass field available
+ $mass = null;
+ if (isset($product['UnitWeightKg']['UnitWeight'])) {
+ $mass = (float) $product['UnitWeightKg']['UnitWeight'];
+ //The mass is given in kg, we want it in g
+ $mass *= 1000;
+ }
+
+
+ $result[] = new PartDetailDTO(
+ provider_key: $this->getProviderKey(),
+ provider_id: $product['MouserPartNumber'],
+ name: $product['ManufacturerPartNumber'],
+ description: $product['Description'],
+ category: $product['Category'],
+ manufacturer: $product['Manufacturer'],
+ mpn: $product['ManufacturerPartNumber'],
+ preview_image_url: $product['ImagePath'],
+ manufacturing_status: $this->releaseStatusCodeToManufacturingStatus(
+ $product['LifecycleStatus'] ?? null,
+ (int) ($product['AvailabilityInStock'] ?? 0)
+ ),
+ provider_url: $product['ProductDetailUrl'],
+ datasheets: $this->parseDataSheets($product['DataSheetUrl'] ?? null,
+ $product['MouserPartNumber'] ?? null),
+ vendor_infos: $this->pricingToDTOs($product['PriceBreaks'] ?? [], $product['MouserPartNumber'],
+ $product['ProductDetailUrl']),
+ mass: $mass,
+ );
+ }
+ return $result;
+ }
+
+
+ private function parseDataSheets(?string $sheetUrl, ?string $sheetName): ?array
+ {
+ if ($sheetUrl === null || $sheetUrl === '' || $sheetUrl === '0') {
+ return null;
+ }
+ $result = [];
+ $result[] = new FileDTO(url: $sheetUrl, name: $sheetName);
+ return $result;
+ }
+
+ /*
+ * Mouser API price is a string in the form "n[.,]nnn[.,] currency"
+ * then this convert it to a number
+ * Austria has a format like "€ 2,10"
+ */
+ private function priceStrToFloat($val): float
+ {
+ //Remove any character that is not a number, dot or comma (like currency symbols)
+ $val = preg_replace('/[^0-9.,]/', '', $val);
+
+ //Trim the string
+ $val = trim($val);
+
+ //Convert commas to dots
+ $val = str_replace(",", ".", $val);
+ //Remove any dot that is not the last one (to avoid problems with thousands separators)
+ $val = preg_replace('/\.(?=.*\.)/', '', $val);
+ return (float)$val;
+ }
+
+ /**
+ * Converts the pricing (StandardPricing field) from the Mouser 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) {
+ $number = $this->priceStrToFloat($price_break['Price']);
+ $prices[] = new PriceDTO(
+ minimum_discount_amount: $price_break['Quantity'],
+ price: (string)$number,
+ currency_iso_code: $price_break['Currency']
+ );
+ }
+
+ return [
+ new PurchaseInfoDTO(distributor_name: self::DISTRIBUTOR_NAME, order_number: $order_number, prices: $prices,
+ product_url: $product_url)
+ ];
+ }
+
+
+ /* Converts the product status from the MOUSER API to the manufacturing status used in Part-DB:
+ Factory Special Order - Ordine speciale in fabbrica
+ Not Recommended for New Designs - Non raccomandato per nuovi progetti
+ New Product - Nuovo prodotto
+ End of Life - Fine vita
+ -vuoto- - Attivo
+
+ TODO: Probably need to review the values of field Lifecyclestatus
+ */
+ /**
+ * Converts the lifecycle status from the Mouser API to a ManufacturingStatus
+ * @param string|null $productStatus The lifecycle status from the Mouser API
+ * @param int $availableInStock The number of parts available in stock
+ * @return ManufacturingStatus|null
+ */
+ private function releaseStatusCodeToManufacturingStatus(?string $productStatus, int $availableInStock = 0): ?ManufacturingStatus
+ {
+ $tmp = match ($productStatus) {
+ null => null,
+ "New Product" => ManufacturingStatus::ANNOUNCED,
+ "Not Recommended for New Designs" => ManufacturingStatus::NRFND,
+ "Factory Special Order", "Obsolete" => ManufacturingStatus::DISCONTINUED,
+ "End of Life" => ManufacturingStatus::EOL,
+ default => ManufacturingStatus::ACTIVE,
+ };
+
+ //If the part would be assumed to be announced, check if it is in stock, then it is active
+ if ($tmp === ManufacturingStatus::ANNOUNCED && $availableInStock > 0) {
+ $tmp = ManufacturingStatus::ACTIVE;
+ }
+
+ return $tmp;
+ }
+}
\ No newline at end of file
diff --git a/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php b/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php
new file mode 100644
index 00000000..ccf800f8
--- /dev/null
+++ b/src/Services/InfoProviderSystem/Providers/OEMSecretsProvider.php
@@ -0,0 +1,1471 @@
+.
+ */
+
+/**
+ * OEMSecretsProvider Class
+ *
+ * This class is responsible for interfacing with the OEMSecrets API (version 3.0.1) to retrieve and manage information
+ * about electronic components. Since the API does not provide a unique identifier for each part, the class aggregates
+ * results based on "part_number" and "manufacturer_id". It also transforms unstructured descriptions into structured
+ * parameters and aggregates datasheets and images provided by multiple distributors.
+ * The OEMSecrets API returns results by matching the provided part number not only with the original part number
+ * but also with the distributor-assigned part number and/or the part description.
+ *
+ * Key functionalities:
+ * - Aggregation of results based on part_number and manufacturer_id to ensure unique identification of parts.
+ * - Conversion of component descriptions into structured parameters (ParameterDTO) for better clarity and searchability.
+ * - Aggregation of datasheets and images from multiple distributors, ensuring that all available resources are collected.
+ * - Price handling, including filtering of distributors that offer zero prices, controlled by the `zero_price` configuration variable.
+ * - A sorting algorithm that first prioritizes exact matches with the keyword, followed by alphabetical sorting of items
+ * with the same prefix (e.g., "BC546", "BC546A", "BC546B"), and finally, sorts by either manufacturer or completeness
+ * based on the specified criteria.
+ * - Sorting the distributors:
+ * 1. Environment's country_code first.
+ * 2. Region matching environment's country_code, prioritizing "Global" ('XX').
+ * 3. Distributors with null country_code/region are placed last.
+ * 4. Final fallback is alphabetical sorting by region and country_code.
+ *
+ * Configuration:
+ * - The ZERO_PRICE variable must be set in the `.env.local` file. If is set to 0, the class will skip distributors
+ * that do not offer valid prices for the components.
+ * - Currency and country settings can also be specified for localized pricing and distributor filtering.
+ * - Generation of parameters: if SET_PARAM is set to 1 the parameters for the part are generated from the description
+ * transforming unstructured descriptions into structured parameters; each parameter in description should have the form:
+ * "...;name1:value1;name2:value2"
+ * - Sorting is guided by SORT_CRITERIA variable. The sorting process first arranges items based on the provided keyword.
+ * Then, if set to 'C', it further sorts by completeness (prioritizing items with the most detailed information).
+ * If set to 'M', it further sorts by manufacturer name. If unset or set to any other value, no sorting is performed.
+ * Distributors within each item are further sorted based on country_code and region, following the rules explained
+ * in the previous comment.
+ *
+ * Data Handling:
+ * - The class divides and stores component information across multiple session arrays:
+ * - `basic_info_results`: Stores basic information like name, description, manufacturer, and category.
+ * - `datasheets_results`: Aggregates datasheets provided by distributors, ensuring no duplicates.
+ * - `images_results`: Collects images of components from various sources, preventing duplication.
+ * - `parameters_results`: Extracts and stores key parameters parsed from component descriptions.
+ * - `purchase_info_results`: Contains detailed purchasing information like pricing and distributor details.
+ *
+ * - By splitting the data into separate session arrays, the class optimizes memory usage and simplifies retrieval
+ * of specific details without loading the entire dataset at once.
+ *
+ * Technical Details:
+ * - Uses OEMSecrets API (version 3.0.1) to retrieve component data.
+ * - Data processing includes sanitizing input, avoiding duplicates, and dynamically adjusting information as new distributor
+ * data becomes available (e.g., adding missing datasheets or parameters from subsequent API responses).
+ *
+ * @package App\Services\InfoProviderSystem\Providers
+ * @author Pasquale D'Orsi (https://github.com/pdo59)
+ * @version 1.2.0
+ * @since 2024 August
+ */
+
+
+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\PartDetailDTO;
+use App\Services\InfoProviderSystem\DTOs\PriceDTO;
+use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
+use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Psr\Cache\CacheItemPoolInterface;
+
+
+class OEMSecretsProvider implements InfoProviderInterface
+{
+
+ private const ENDPOINT_URL = 'https://oemsecretsapi.com/partsearch';
+
+ public function __construct(
+ private readonly HttpClientInterface $oemsecretsClient,
+ private readonly string $api_key,
+ private readonly string $country_code,
+ private readonly string $currency,
+ private readonly string $zero_price,
+ private readonly string $set_param,
+ private readonly string $sort_criteria,
+ private readonly CacheItemPoolInterface $partInfoCache
+ )
+ {
+ }
+
+ private array $countryNameToCodeMap = [
+ 'Andorra' => 'AD',
+ 'United Arab Emirates' => 'AE',
+ 'Antarctica' => 'AQ',
+ 'Argentina' => 'AR',
+ 'Austria' => 'AT',
+ 'Australia' => 'AU',
+ 'Belgium' => 'BE',
+ 'Bolivia' => 'BO',
+ 'Brazil' => 'BR',
+ 'Bouvet Island' => 'BV',
+ 'Belarus' => 'BY',
+ 'Canada' => 'CA',
+ 'Switzerland' => 'CH',
+ 'Chile' => 'CL',
+ 'China' => 'CN',
+ 'Colombia' => 'CO',
+ 'Czech Republic' => 'CZ',
+ 'Germany' => 'DE',
+ 'Denmark' => 'DK',
+ 'Ecuador' => 'EC',
+ 'Estonia' => 'EE',
+ 'Western Sahara' => 'EH',
+ 'Spain' => 'ES',
+ 'Finland' => 'FI',
+ 'Falkland Islands' => 'FK',
+ 'Faroe Islands' => 'FO',
+ 'France' => 'FR',
+ 'United Kingdom' => 'GB',
+ 'Georgia' => 'GE',
+ 'French Guiana' => 'GF',
+ 'Guernsey' => 'GG',
+ 'Gibraltar' => 'GI',
+ 'Greenland' => 'GL',
+ 'Greece' => 'GR',
+ 'South Georgia and the South Sandwich Islands' => 'GS',
+ 'Guyana' => 'GY',
+ 'Hong Kong' => 'HK',
+ 'Heard Island and McDonald Islands' => 'HM',
+ 'Croatia' => 'HR',
+ 'Hungary' => 'HU',
+ 'Ireland' => 'IE',
+ 'Isle of Man' => 'IM',
+ 'India' => 'IN',
+ 'Iceland' => 'IS',
+ 'Italy' => 'IT',
+ 'Jamaica' => 'JM',
+ 'Japan' => 'JP',
+ 'North Korea' => 'KP',
+ 'South Korea' => 'KR',
+ 'Kazakhstan' => 'KZ',
+ 'Liechtenstein' => 'LI',
+ 'Sri Lanka' => 'LK',
+ 'Lithuania' => 'LT',
+ 'Luxembourg' => 'LU',
+ 'Monaco' => 'MC',
+ 'Moldova' => 'MD',
+ 'Montenegro' => 'ME',
+ 'North Macedonia' => 'MK',
+ 'Malta' => 'MT',
+ 'Netherlands' => 'NL',
+ 'Norway' => 'NO',
+ 'New Zealand' => 'NZ',
+ 'Peru' => 'PE',
+ 'Philippines' => 'PH',
+ 'Poland' => 'PL',
+ 'Portugal' => 'PT',
+ 'Paraguay' => 'PY',
+ 'Romania' => 'RO',
+ 'Serbia' => 'RS',
+ 'Russia' => 'RU',
+ 'Solomon Islands' => 'SB',
+ 'Sudan' => 'SD',
+ 'Sweden' => 'SE',
+ 'Singapore' => 'SG',
+ 'Slovenia' => 'SI',
+ 'Svalbard and Jan Mayen' => 'SJ',
+ 'Slovakia' => 'SK',
+ 'San Marino' => 'SM',
+ 'Somalia' => 'SO',
+ 'Suriname' => 'SR',
+ 'Syria' => 'SY',
+ 'Eswatini' => 'SZ',
+ 'Turks and Caicos Islands' => 'TC',
+ 'French Southern Territories' => 'TF',
+ 'Togo' => 'TG',
+ 'Thailand' => 'TH',
+ 'Tajikistan' => 'TJ',
+ 'Tokelau' => 'TK',
+ 'Turkmenistan' => 'TM',
+ 'Tunisia' => 'TN',
+ 'Tonga' => 'TO',
+ 'Turkey' => 'TR',
+ 'Trinidad and Tobago' => 'TT',
+ 'Tuvalu' => 'TV',
+ 'Taiwan' => 'TW',
+ 'Tanzania' => 'TZ',
+ 'Ukraine' => 'UA',
+ 'Uganda' => 'UG',
+ 'United States Minor Outlying Islands' => 'UM',
+ 'United States' => 'US',
+ 'Uruguay' => 'UY',
+ 'Uzbekistan' => 'UZ',
+ 'Vatican City' => 'VA',
+ 'Venezuela' => 'VE',
+ 'British Virgin Islands' => 'VG',
+ 'U.S. Virgin Islands' => 'VI',
+ 'Vietnam' => 'VN',
+ 'Vanuatu' => 'VU',
+ 'Wallis and Futuna' => 'WF',
+ 'Yemen' => 'YE',
+ 'South Africa' => 'ZA',
+ 'Zambia' => 'ZM',
+ 'Zimbabwe' => 'ZW',
+ 'Global' => 'XX'
+ ];
+
+ private array $distributorCountryCodes = [];
+ private array $countryCodeToRegionMap = [];
+
+ /**
+ * 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
+ {
+ return [
+ 'name' => 'OEMSecrets',
+ 'description' => 'This provider uses the OEMSecrets API to search for parts.',
+ 'url' => 'https://www.oemsecrets.com/',
+ 'disabled_help' => 'Configure the API key in the PROVIDER_OEMSECRETS_KEY environment variable to enable.'
+ ];
+ }
+ /**
+ * 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
+ {
+ return 'oemsecrets';
+ }
+
+ /**
+ * 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
+ {
+ return $this->api_key !== '';
+ }
+
+
+ /**
+ * Searches for products based on a given keyword using the OEMsecrets Part Search API.
+ *
+ * This method queries the OEMsecrets API to retrieve distributor data for the provided part number,
+ * including details such as pricing, compliance, and inventory. It supports both direct API queries
+ * and debugging with local JSON files. The results are processed, cached, and then sorted based
+ * on the keyword and specified criteria.
+ *
+ * @param string $keyword The part number to search for
+ * @return array An array of processed product details, sorted by relevance and additional criteria.
+ *
+ * @throws \Exception If the JSON file used for debugging is not found or contains errors.
+ */
+ public function searchByKeyword(string $keyword): array
+ {
+ /*
+ oemsecrets Part Search API 3.0.1
+
+ "https://oemsecretsapi.com/partsearch?
+ searchTerm=BC547
+ &apiKey=icawpb0bspoo2c6s64uv4vpdfp2vgr7e27bxw0yct2bzh87mpl027x353uelpq2x
+ ¤cy=EUR
+ &countryCode=IT"
+
+ partsearch description:
+ Use the Part Search API to find distributor data for a full or partial manufacturer
+ part number including part details, pricing, compliance and inventory.
+
+ Required Parameter Format Description
+ searchTerm string Part number you are searching for
+ apiKey string Your unique API key provided to you by OEMsecrets
+
+ Additional Parameter Format Description
+ countryCode string The country you want to output for
+ currency string / array The currency you want the prices to be displayed as
+
+ To display the output for GB and to view prices in USD, add [ countryCode=GB ] and [ currency=USD ]
+ as seen below:
+ oemsecretsapi.com/partsearch?apiKey=abcexampleapikey123&searchTerm=bd04&countryCode=GB¤cy=USD
+
+ To view prices in both USD and GBP add [ currency[]=USD¤cy[]=GBP ]
+ oemsecretsapi.com/partsearch?searchTerm=bd04&apiKey=abcexampleapikey123¤cy[]=USD¤cy[]=GBP
+
+ */
+
+
+ // Activate this block when querying the real APIs
+ //------------------
+
+ $response = $this->oemsecretsClient->request('GET', self::ENDPOINT_URL, [
+ 'query' => [
+ 'searchTerm' => $keyword,
+ 'apiKey' => $this->api_key,
+ 'currency' => $this->currency,
+ 'countryCode' => $this->country_code,
+ ],
+ ]);
+
+ $response_array = $response->toArray();
+ //------------------*/
+
+ // Or activate this block when we use json file for debugging
+ /*/------------------
+ $jsonFilePath = '';
+ if (!file_exists($jsonFilePath)) {
+ throw new \Exception("JSON file not found.");
+ }
+ $jsonContent = file_get_contents($jsonFilePath);
+ $response_array = json_decode($jsonContent, true);
+
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ throw new \Exception("JSON file decode failed: " . json_last_error_msg());
+ }
+ //------------------*/
+
+ $products = $response_array['stock'] ?? [];
+
+ $results = [];
+ $basicInfoResults = [];
+ $datasheetsResults = [];
+ $imagesResults = [];
+ $parametersResults = [];
+ $purchaseInfoResults = [];
+
+ foreach ($products as $product) {
+ if (!isset($product['part_number'], $product['manufacturer'])) {
+ continue; // Skip invalid product entries
+ }
+ $provider_id = $this->generateProviderId($product['part_number'], $product['manufacturer']);
+
+ $partDetailDTO = $this->processBatch(
+ $product,
+ $provider_id,
+ $basicInfoResults,
+ $datasheetsResults,
+ $imagesResults,
+ $parametersResults,
+ $purchaseInfoResults
+ );
+
+ if ($partDetailDTO !== null) {
+ $results[$provider_id] = $partDetailDTO;
+ $cacheKey = $this->getCacheKey($provider_id);
+ $cacheItem = $this->partInfoCache->getItem($cacheKey);
+ $cacheItem->set($partDetailDTO);
+ $cacheItem->expiresAfter(3600 * 24);
+ $this->partInfoCache->save($cacheItem);
+ }
+ }
+
+ //Force garbage collection to free up memory
+ gc_collect_cycles();
+
+ // Sort of the results
+ $this->sortResultsData($results, $keyword);
+
+ //Force garbage collection to free up memory
+ gc_collect_cycles();
+
+ return $results;
+
+ }
+
+ /**
+ * Generates a cache key for storing part details based on the provided provider ID.
+ *
+ * This method creates a unique cache key by prefixing the provider ID with 'part_details_'
+ * and hashing the provider ID using MD5 to ensure a consistent and compact key format.
+ *
+ * @param string $provider_id The unique identifier of the provider or part.
+ * @return string The generated cache key.
+ */
+ private function getCacheKey(string $provider_id): string {
+ return 'oemsecrets_part_' . md5($provider_id);
+ }
+
+
+ /**
+ * Retrieves detailed information about the part with the given provider ID from the cache.
+ *
+ * This method checks the cache for the details of the specified part. If the details are
+ * found in the cache, they are returned. If not, an exception is thrown indicating that
+ * the details could not be found.
+ *
+ * @param string $id The unique identifier of the provider or part.
+ * @return PartDetailDTO The detailed information about the part.
+ *
+ * @throws \Exception If no details are found for the given provider ID.
+ */
+ public function getDetails(string $id): PartDetailDTO
+ {
+ $cacheKey = $this->getCacheKey($id);
+ $cacheItem = $this->partInfoCache->getItem($cacheKey);
+
+ if ($cacheItem->isHit()) {
+ return $cacheItem->get();
+ }
+ //If we have no cached result yet, we extract the part number (first part of our ID) and search for it
+ $partNumber = explode('|', $id)[0];
+
+ //The searchByKeyword method will write the results to cache, so we can just try it again afterwards
+ $this->searchByKeyword($partNumber);
+
+ $cacheItem = $this->partInfoCache->getItem($cacheKey);
+ if ($cacheItem->isHit()) {
+ return $cacheItem->get();
+ }
+
+ // If the details still are not found in the cache, throw an exception
+ throw new \RuntimeException("Details not found for provider_id $id");
+ }
+
+
+ /**
+ * 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
+ {
+ return [
+ ProviderCapabilities::BASIC,
+ ProviderCapabilities::PICTURE,
+ ProviderCapabilities::DATASHEET,
+ ProviderCapabilities::PRICE,
+ ];
+ }
+
+
+ /**
+ * Processes a single product and updates arrays for basic information, datasheets, images, parameters,
+ * and purchase information. Aggregates and organizes data received for a specific `part_number` and `manufacturer_id`.
+ * Distributors within the product are also sorted based on country_code and region.
+ *
+ * @param array $product The product data received from the OEMSecrets API.
+ * @param string $provider_id A string that contains the unique key created for the part
+ * @param array &$basicInfoResults Array containing the basic product information (e.g., name, description, category).
+ * @param array &$datasheetsResults Array containing datasheets collected from various distributors for the product.
+ * @param array &$imagesResults Array containing images of the product collected from various distributors.
+ * @param array &$parametersResults Array containing technical parameters extracted from the product descriptions.
+ * @param array &$purchaseInfoResults Array containing purchase information, including distributors and pricing details.
+ *
+ * @return PartDetailDTO|null Returns a PartDetailDTO object if the product is processed successfully, otherwise null.
+ *
+ * @throws \Exception If a required key in the product data is missing or if there is an issue creating the DTO.
+ *
+ * @see createOrUpdateBasicInfo() Creates or updates the basic product information.
+ * @see getPrices() Extracts the pricing information for the product.
+ * @see parseDataSheets() Parses and prevents duplication of datasheets.
+ * @see getImages() Extracts and avoids duplication of images.
+ * @see getParameters() Extracts technical parameters from the product description.
+ * @see createPurchaseInfoDTO() Creates a PurchaseInfoDTO containing distributor and price information.
+ *
+ * @note Distributors within the product are sorted by country_code and region:
+ * 1. Distributors with the environment's country_code come first.
+ * 2. Distributors in the same region as the environment's country_code are next,
+ * with "Global" ('XX') prioritized within this region.
+ * 3. Distributors with null country_code or region are placed last.
+ * 4. Remaining distributors are sorted alphabetically by region and country_code.
+
+ */
+ private function processBatch(
+ array $product,
+ string $provider_id,
+ array &$basicInfoResults,
+ array &$datasheetsResults,
+ array &$imagesResults,
+ array &$parametersResults,
+ array &$purchaseInfoResults
+ ): ?PartDetailDTO
+ {
+ if (!isset($product['manufacturer'], $product['part_number'])) {
+ throw new \InvalidArgumentException("Missing required product data: 'manufacturer' or 'part_number'");
+ }
+
+ // Retrieve the country_code associated with the distributor and store it in the $distributorCountryCodes array.
+ $distributorCountry = $product['distributor']['distributor_country'] ?? null;
+ $distributorName = $product['distributor']['distributor_name'] ?? null;
+ $distributorRegion = $product['distributor']['distributor_region'] ?? null;
+
+ if ($distributorCountry && $distributorName) {
+ $countryCode = $this->mapCountryNameToCode($distributorCountry);
+ if ($countryCode) {
+ $this->distributorCountryCodes[$distributorName] = $countryCode;
+ }
+ if ($distributorRegion) {
+ $this->countryCodeToRegionMap[$countryCode] = $distributorRegion;
+ }
+ }
+
+ // Truncate the description and handle notes
+ $thenotes = '';
+ $description = $product['description'] ?? '';
+ if (strlen($description) > 100) {
+ $thenotes = $description; // Save the complete description
+ $description = substr($description, 0, 100) . '...'; // Truncate the description
+ }
+
+ // Extract prices
+ $priceDTOs = $this->getPrices($product);
+ if (empty($priceDTOs) && (int)$this->zero_price === 0) {
+ return null; // Skip products without valid prices
+ }
+
+ $existingBasicInfo = isset($basicInfoResults[$provider_id]) && is_array($basicInfoResults[$provider_id])
+ ? $basicInfoResults[$provider_id]
+ : [];
+
+ $basicInfoResults[$provider_id] = $this->createOrUpdateBasicInfo(
+ $provider_id,
+ $product,
+ $description,
+ $thenotes,
+ $existingBasicInfo
+ );
+
+ // Update images, datasheets, and parameters
+
+ $newDatasheets = $this->parseDataSheets($product['datasheet_url'] ?? null, null, $datasheetsResults[$provider_id] ?? []);
+ if ($newDatasheets !== null) {
+ $datasheetsResults[$provider_id] = array_merge($datasheetsResults[$provider_id] ?? [], $newDatasheets);
+ }
+
+ $imagesResults[$provider_id] = $this->getImages($product, $imagesResults[$provider_id] ?? []);
+ if ($this->set_param == 1) {
+ $parametersResults[$provider_id] = $this->getParameters($product, $parametersResults[$provider_id] ?? []);
+ } else {
+ $parametersResults[$provider_id] = [];
+ }
+
+ // Handle purchase information
+ $currentDistributor = $this->createPurchaseInfoDTO($product, $priceDTOs, $purchaseInfoResults[$provider_id] ?? []);
+ if ($currentDistributor !== null) {
+ $purchaseInfoResults[$provider_id][] = $currentDistributor;
+ }
+
+ // If there is data in $purchaseInfoResults, sort it before creating the PartDetailDTO
+ if (!empty($purchaseInfoResults[$provider_id])) {
+ usort($purchaseInfoResults[$provider_id], function ($a, $b) {
+ $nameA = $a->distributor_name;
+ $nameB = $b->distributor_name;
+
+ $countryCodeA = $this->distributorCountryCodes[$nameA] ?? null;
+ $countryCodeB = $this->distributorCountryCodes[$nameB] ?? null;
+
+ $regionA = $this->countryCodeToRegionMap[$countryCodeA] ?? '';
+ $regionB = $this->countryCodeToRegionMap[$countryCodeB] ?? '';
+
+ // If the map is empty or doesn't contain the key for $this->country_code, assign a placeholder region.
+ $regionForEnvCountry = $this->countryCodeToRegionMap[$this->country_code] ?? '';
+
+ // Convert to string before comparison to avoid mixed types
+ $countryCodeA = (string) $countryCodeA;
+ $countryCodeB = (string) $countryCodeB;
+ $regionA = (string) $regionA;
+ $regionB = (string) $regionB;
+
+
+ // Step 0: If either country code is null, place it at the end
+ if ($countryCodeA === '' || $regionA === '') {
+ return 1; // Metti A dopo B
+ } elseif ($countryCodeB === '' || $regionB === '') {
+ return -1; // Metti B dopo A
+ }
+
+ // Step 1: country_code from the environment
+ if ($countryCodeA === $this->country_code && $countryCodeB !== $this->country_code) {
+ return -1;
+ } elseif ($countryCodeA !== $this->country_code && $countryCodeB === $this->country_code) {
+ return 1;
+ }
+
+ // Step 2: Sort by environment's region, prioritizing "Global" (XX)
+ if ($regionA === $regionForEnvCountry && $regionB !== $regionForEnvCountry) {
+ return -1;
+ } elseif ($regionA !== $regionForEnvCountry && $regionB === $regionForEnvCountry) {
+ return 1;
+ }
+
+ // Step 3: If regions are the same, prioritize "Global" (XX)
+ if ($regionA === $regionB) {
+ if ($countryCodeA === 'XX' && $countryCodeB !== 'XX') {
+ return -1;
+ } elseif ($countryCodeA !== 'XX' && $countryCodeB === 'XX') {
+ return 1;
+ }
+ }
+
+ // Step 4: Alphabetical sorting by region and country_code
+ $regionComparison = strcasecmp($regionA , $regionB);
+ if ($regionComparison !== 0) {
+ return $regionComparison;
+ }
+
+ // Alphabetical sorting as a fallback
+ return strcasecmp($countryCodeA, $countryCodeB);
+ });
+ }
+ // Convert the gathered data into a PartDetailDTO
+
+ $partDetailDTO = new PartDetailDTO(
+ provider_key: $basicInfoResults[$provider_id]['provider_key'],
+ provider_id: $provider_id,
+ name: $basicInfoResults[$provider_id]['name'],
+ description: $basicInfoResults[$provider_id]['description'],
+ category: $basicInfoResults[$provider_id]['category'],
+ manufacturer: $basicInfoResults[$provider_id]['manufacturer'],
+ mpn: $basicInfoResults[$provider_id]['mpn'],
+ preview_image_url: $basicInfoResults[$provider_id]['preview_image_url'],
+ manufacturing_status: $basicInfoResults[$provider_id]['manufacturing_status'],
+ provider_url: $basicInfoResults[$provider_id]['provider_url'],
+ footprint: $basicInfoResults[$provider_id]['footprint'] ?? null,
+ notes: $basicInfoResults[$provider_id]['notes'] ?? null,
+ datasheets: $datasheetsResults[$provider_id] ?? [],
+ images: $imagesResults[$provider_id] ?? [],
+ parameters: $parametersResults[$provider_id] ?? [],
+ vendor_infos: $purchaseInfoResults[$provider_id] ?? []
+ );
+
+ return $partDetailDTO;
+ }
+
+
+ /**
+ * Extracts pricing information from the product data, converts it to PriceDTO objects,
+ * and returns them as an array.
+ *
+ * @param array{
+ * prices?: array>,
+ * source_currency?: string
+ * } $product The product data from the OEMSecrets API containing price details.
+ *
+ * @return PriceDTO[] Array of PriceDTO objects representing different price tiers for the product.
+ */
+ private function getPrices(array $product): array
+ {
+ $prices = $product['prices'] ?? [];
+ $sourceCurrency = $product['source_currency'] ?? null;
+ $priceDTOs = [];
+
+ // Flag to check if we have added prices in the preferred currency
+ $foundPreferredCurrency = false;
+
+ if (is_array($prices)) {
+ // Step 1: Check if prices exist in the preferred currency
+ if (isset($prices[$this->currency]) && is_array($prices[$this->currency])) {
+ $priceDetails = $prices[$this->currency];
+ foreach ($priceDetails as $priceDetail) {
+ if (
+ is_array($priceDetail) &&
+ isset($priceDetail['unit_break'], $priceDetail['unit_price']) &&
+ is_numeric($priceDetail['unit_break']) &&
+ is_string($priceDetail['unit_price']) &&
+ $priceDetail['unit_price'] !== "0.0000"
+ ) {
+ $priceDTOs[] = new PriceDTO(
+ minimum_discount_amount: (float)$priceDetail['unit_break'],
+ price: (string)$priceDetail['unit_price'],
+ currency_iso_code: $this->currency,
+ includes_tax: false,
+ price_related_quantity: 1.0
+ );
+ $foundPreferredCurrency = true;
+ }
+ }
+ }
+
+ // Step 2: If no prices in the preferred currency, use source currency
+ if (!$foundPreferredCurrency && $sourceCurrency && isset($prices[$sourceCurrency]) && is_array($prices[$sourceCurrency])) {
+ $priceDetails = $prices[$sourceCurrency];
+ foreach ($priceDetails as $priceDetail) {
+ if (
+ is_array($priceDetail) &&
+ isset($priceDetail['unit_break'], $priceDetail['unit_price']) &&
+ is_numeric($priceDetail['unit_break']) &&
+ is_string($priceDetail['unit_price']) &&
+ $priceDetail['unit_price'] !== "0.0000"
+ ) {
+ $priceDTOs[] = new PriceDTO(
+ minimum_discount_amount: (float)$priceDetail['unit_break'],
+ price: (string)$priceDetail['unit_price'],
+ currency_iso_code: $sourceCurrency,
+ includes_tax: false,
+ price_related_quantity: 1.0
+ );
+ }
+ }
+ }
+ }
+
+ return $priceDTOs;
+ }
+
+
+ /**
+ * Retrieves product images provided by the distributor. Prevents duplicates based on the image name.
+ * @param array{
+ * image_url?: string
+ * } $product The product data from the OEMSecrets API containing image URLs.
+ * @param FileDTO[] $existingImages Optional. Existing images for the product to avoid duplicates.
+ *
+ * @return FileDTO[] Array of FileDTO objects representing the product images.
+ */
+ private function getImages(array $product, array $existingImages = []): array
+ {
+ $images = $existingImages;
+ $imageUrl = $product['image_url'] ?? null;
+
+ if ($imageUrl) {
+ $imageName = basename(parse_url($imageUrl, PHP_URL_PATH));
+ if (!in_array($imageName, array_column($images, 'name'), true)) {
+ $images[] = new FileDTO(url: $imageUrl, name: $imageName);
+ }
+ }
+ return $images;
+ }
+
+ /**
+ * Extracts technical parameters from the product description, ensures no duplicates, and returns them as an array.
+ *
+ * @param array{
+ * description?: string
+ * } $product The product data from the OEMSecrets API containing product descriptions.
+ * @param ParameterDTO[] $existingParameters Optional. Existing parameters for the product to avoid duplicates.
+ *
+ * @return ParameterDTO[] Array of ParameterDTO objects representing technical parameters extracted from the product description.
+ */
+ private function getParameters(array $product, array $existingParameters = []): array
+ {
+ $parameters = $existingParameters;
+ $description = $product['description'] ?? '';
+
+ // Logic to extract parameters from the description
+ $extractedParameters = $this->parseDescriptionToParameters($description) ?? [];
+
+ foreach ($extractedParameters as $newParam) {
+ $isDuplicate = false;
+ foreach ($parameters as $existingParam) {
+ if ($existingParam->name === $newParam->name) {
+ $isDuplicate = true;
+ break;
+ }
+ }
+ if (!$isDuplicate) {
+ $parameters[] = $newParam;
+ }
+ }
+
+ return $parameters;
+ }
+
+ /**
+ * Creates a PurchaseInfoDTO object containing distributor and pricing information for a product.
+ * Ensures that the distributor name is valid and prices are available.
+ *
+ * @param array{
+ * distributor?: array{
+ * distributor_name?: string
+ * },
+ * sku?: string,
+ * source_part_number: string,
+ * buy_now_url?: string,
+ * lead_time_weeks?: mixed
+ * } $product The product data from the OEMSecrets API.
+ * @param PriceDTO[] $priceDTOs Array of PriceDTO objects representing pricing tiers.
+ * @param PurchaseInfoDTO[] $existingPurchaseInfos Optional. Existing purchase information for the product to avoid duplicates.
+ *
+ * @return PurchaseInfoDTO|null A PurchaseInfoDTO object containing the distributor information, or null if invalid.
+ */
+ private function createPurchaseInfoDTO(array $product, array $priceDTOs, array $existingPurchaseInfos = []): ?PurchaseInfoDTO
+ {
+ $distributor_name = $product['distributor']['distributor_name'] ?? null;
+ if ($distributor_name && !empty($priceDTOs)) {
+ $sku = isset($product['sku']) ? (string)$product['sku'] : null;
+ $order_number_base = $sku ?: (string)$product['source_part_number'];
+ $order_number = $order_number_base;
+
+ // Remove duplicates from the quantity/price tiers
+ $uniquePriceDTOs = [];
+ foreach ($priceDTOs as $priceDTO) {
+ $key = $priceDTO->minimum_discount_amount . '-' . $priceDTO->price;
+ $uniquePriceDTOs[$key] = $priceDTO;
+ }
+ $priceDTOs = array_values($uniquePriceDTOs);
+
+ // Differentiate $order_number if duplicated
+ if ($this->isDuplicateOrderNumber($order_number, $distributor_name, $existingPurchaseInfos)) {
+ $lead_time_weeks = isset($product['lead_time_weeks']) ? (string)$product['lead_time_weeks'] : '';
+ $order_number = $order_number_base . '-' . $lead_time_weeks;
+
+ // If there is still a duplicate after adding lead_time_weeks
+ $counter = 1;
+ while ($this->isDuplicateOrderNumber($order_number, $distributor_name, $existingPurchaseInfos)) {
+ $order_number = $order_number_base . '-' . $lead_time_weeks . '-' . $counter;
+ $counter++;
+ }
+ }
+
+ return new PurchaseInfoDTO(
+ distributor_name: $distributor_name,
+ order_number: $order_number,
+ prices: $priceDTOs,
+ product_url: $this->unwrapURL($product['buy_now_url'] ?? null)
+ );
+ }
+ return null; // Return null if no valid distributor exists
+ }
+
+ /**
+ * Checks if an order number already exists for a given distributor in the existing purchase infos.
+ *
+ * @param string $order_number The order number to check.
+ * @param string $distributor_name The name of the distributor.
+ * @param PurchaseInfoDTO[] $existingPurchaseInfos The existing purchase information to check against.
+ * @return bool True if a duplicate order number is found, otherwise false.
+ */
+ private function isDuplicateOrderNumber(string $order_number, string $distributor_name, array $existingPurchaseInfos): bool
+ {
+ foreach ($existingPurchaseInfos as $purchaseInfo) {
+ if ($purchaseInfo->distributor_name === $distributor_name && $purchaseInfo->order_number === $order_number) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Creates or updates the basic information of a product, including the description, category, manufacturer,
+ * and other metadata. This function manages the PartDetailDTO creation or update.
+ *
+ * @param string $provider_id The unique identifier for the product based on part_number and manufacturer.
+ * * @param array{
+ * part_number: string,
+ * category: string,
+ * manufacturer: string,
+ * source_part_number: string,
+ * image_url?: string,
+ * life_cycle?: string,
+ * quantity_in_stock?: int
+ * } $product The product data from the OEMSecrets API.
+ * @param string $description The truncated description for the product.
+ * @param string $thenotes The full description saved as notes for the product.
+ *
+ * @return array The updated or newly created PartDetailDTO containing basic product information.
+ */
+ private function createOrUpdateBasicInfo(
+ string $provider_id,
+ array $product,
+ string $description,
+ string $thenotes,
+ ?array $existingBasicInfo
+ ): array {
+ // If there is no existing basic info array, we create a new one
+ if (is_null($existingBasicInfo)) {
+ return [
+ 'provider_key' => $this->getProviderKey(),
+ 'provider_id' => $provider_id,
+ 'name' => $product['part_number'],
+ 'description' => $description,
+ 'category' => $product['category'],
+ 'manufacturer' => $product['manufacturer'],
+ 'mpn' => $product['source_part_number'],
+ 'preview_image_url' => $product['image_url'] ?? null,
+ 'manufacturing_status' => $this->releaseStatusCodeToManufacturingStatus(
+ $product['life_cycle'] ?? null,
+ (int)($product['quantity_in_stock'] ?? 0)
+ ),
+ 'provider_url' => $this->generateInquiryUrl($product['part_number']),
+ 'notes' => $thenotes,
+ 'footprint' => null
+ ];
+ }
+
+ // Update fields only if empty or undefined, with additional check for preview_image_url
+ return [
+ 'provider_key' => $existingBasicInfo['provider_key'] ?? $this->getProviderKey(),
+ 'provider_id' => $existingBasicInfo['provider_id'] ?? $provider_id,
+ 'name' => $existingBasicInfo['name'] ?? $product['part_number'],
+ // Update description if it's null/empty
+ 'description' => !empty($existingBasicInfo['description'])
+ ? $existingBasicInfo['description']
+ : $description,
+ // Update category if it's null/empty
+ 'category' => !empty($existingBasicInfo['category'])
+ ? $existingBasicInfo['category']
+ : $product['category'],
+ 'manufacturer' => $existingBasicInfo['manufacturer'] ?? $product['manufacturer'],
+ 'mpn' => $existingBasicInfo['mpn'] ?? $product['source_part_number'],
+ 'preview_image_url' => !empty($existingBasicInfo['preview_image_url'])
+ ? $existingBasicInfo['preview_image_url']
+ : ($product['image_url'] ?? null),
+ 'manufacturing_status' => !empty($existingBasicInfo['manufacturing_status'])
+ ? $existingBasicInfo['manufacturing_status']
+ : $this->releaseStatusCodeToManufacturingStatus(
+ $product['life_cycle'] ?? null,
+ (int)($product['quantity_in_stock'] ?? 0)
+ ),
+ 'provider_url' => $existingBasicInfo['provider_url'] ?? $this->generateInquiryUrl($product['part_number']), // ?? $product['buy_now_url'],
+ 'notes' => $existingBasicInfo['notes'] ?? $thenotes,
+ 'footprint' => null
+ ];
+ }
+
+ /**
+ * Parses the datasheet URL and returns an array of FileDTO objects representing the datasheets.
+ * If the datasheet name is not provided, it attempts to extract the file name from the URL.
+ * If multiple datasheets with the same default name are encountered, the function appends a
+ * numeric suffix to ensure uniqueness.
+ * The query parameter used to extract the event link can be customized.
+ *
+ * URL Requirements:
+ * - The URL should be a valid URL string.
+ * - The URL can include a query parameter named "event_link", which contains a sub-URL where the
+ * actual datasheet file name is located (e.g., a link to a PDF file).
+ * - If "event_link" is not present, the function attempts to extract the file name directly from
+ * the URL path.
+ * - The URL path should ideally end with a valid file extension (e.g., .pdf, .doc, .xls, etc.).
+ *
+ * Example 1:
+ * Given URL: `https://example.com/datasheet.php?event_link=https%3A%2F%2Ffiles.example.com%2Fdatasheet.pdf`
+ * Extracted name: `datasheet.pdf`
+ *
+ * Example 2:
+ * Given URL: `https://example.com/files/datasheet.pdf`
+ * Extracted name: `datasheet.pdf`
+ *
+ * Example 3 (default name fallback):
+ * Given URL: `https://example.com/files/noextensionfile`
+ * Extracted name: `datasheet.pdf`
+ *
+ * @param string|null $sheetUrl The URL of the datasheet.
+ * @param string|null $sheetName The optional name of the datasheet. If null, the name is extracted from the URL.
+ * @param array $existingDatasheets The array of existing datasheets to check for duplicates.
+ *
+ * @return FileDTO[]|null Returns an array containing the new datasheet if unique, or null if the datasheet is a duplicate or invalid.
+ *
+ * @see FileDTO Used to create datasheet objects with a URL and name.
+ */
+ private function parseDataSheets(?string $sheetUrl, ?string $sheetName, array $existingDatasheets = []): ?array
+ {
+ if ($sheetUrl === null || $sheetUrl === '' || $sheetUrl === '0') {
+ return null;
+ }
+
+ //Unwrap the URL (remove analytics part)
+ $sheetUrl = $this->unwrapURL($sheetUrl);
+
+ // If the datasheet name is not provided, extract it from the URL
+ if ($sheetName === null) {
+ $urlPath = parse_url($sheetUrl, PHP_URL_PATH);
+ if ($urlPath === false) {
+ throw new \RuntimeException("Invalid URL path: $sheetUrl");
+ }
+
+ // If "event_link" does not exist, try to extract the name from the main URL path
+ $sheetName = basename($urlPath);
+ if (!str_contains($sheetName, '.') || !preg_match('/\.(pdf|doc|docx|xls|xlsx|ppt|pptx)$/i', $sheetName)) {
+ // If the name does not have a valid extension, assign a default name
+ $sheetName = 'datasheet_' . uniqid('', true) . '.pdf';
+ }
+ }
+
+ // Create an array of existing file names
+ $existingNames = array_map(static function ($existingDatasheet) {
+ return $existingDatasheet->name;
+ }, $existingDatasheets);
+
+ // Check if the name already exists
+ if (in_array($sheetName, $existingNames, true)) {
+ // The name already exists, so do not add the datasheet
+ return null;
+ }
+
+ // Create an array with the datasheet data if it does not already exist
+ $result = [];
+ $result[] = new FileDTO(url: $sheetUrl, name: $sheetName);
+ return $result;
+ }
+
+ /**
+ * Converts the lifecycle status from the API to a ManufacturingStatus
+ * - "Factory Special Order" / "Ordine speciale in fabbrica"
+ * - "Not Recommended for New Designs" / "Non raccomandato per nuovi progetti"
+ * - "New Product" / "Nuovo prodotto" (if availableInStock > 0 else ANNOUNCED)
+ * - "End of Life" / "Fine vita"
+ * - vuoto / "Attivo"
+ *
+ * @param string|null $productStatus The lifecycle status from the Mouser API. Expected values are:
+ * - "Factory Special Order"
+ * - "Not Recommended for New Designs"
+ * - "New Product"
+ * - "End of Life"
+ * - "Obsolete"
+ * @param int $availableInStock The number of parts available in stock.
+ * @return ManufacturingStatus|null Returns the corresponding ManufacturingStatus or null if the status is unknown.
+ *
+ * @todo Probably need to review the values of field Lifecyclestatus.
+ */
+ private function releaseStatusCodeToManufacturingStatus(?string $productStatus, int $availableInStock = 0): ?ManufacturingStatus
+ {
+ $tmp = match ($productStatus) {
+ null => null,
+ "New Product" => ManufacturingStatus::ANNOUNCED,
+ "Not Recommended for New Designs" => ManufacturingStatus::NRFND,
+ "Factory Special Order", "Obsolete" => ManufacturingStatus::DISCONTINUED,
+ "End of Life" => ManufacturingStatus::EOL,
+ default => null, //ManufacturingStatus::ACTIVE,
+ };
+
+ //If the part would be assumed to be announced, check if it is in stock, then it is active
+ if ($tmp === ManufacturingStatus::ANNOUNCED && $availableInStock > 0) {
+ $tmp = ManufacturingStatus::ACTIVE;
+ }
+
+ return $tmp;
+ }
+
+ /**
+ * Parses the given product description to extract parameters and convert them into `ParameterDTO` objects.
+ * If the description contains only a single `:`, it is considered unstructured and ignored.
+ * The function processes the description by searching for key-value pairs in the format `name: value`,
+ * ignoring any parts of the description that do not follow this format. Parameters are split using either
+ * `;` or `,` as separators.
+ *
+ * The extraction logic handles typical values, ranges, units, and textual information from the description.
+ * If the description is empty or cannot be processed into valid parameters, the function returns null.
+ *
+ * @param string|null $description The description text from which parameters are to be extracted.
+ *
+ * @return ParameterDTO[]|null Returns an array of `ParameterDTO` objects if parameters are successfully extracted,
+ * or null if no valid parameters can be extracted from the description.
+ */
+ private function parseDescriptionToParameters(?string $description): ?array
+ {
+ // If the description is null or empty, return null
+ if ($description === null || trim($description) === '') {
+ return null;
+ }
+
+ // If the description contains only a single ':', return null
+ if (substr_count($description, ':') === 1) {
+ return null;
+ }
+
+ // Array to store parsed parameters
+ $parameters = [];
+
+ // Split the description using the ';' separator
+ $parts = preg_split('/[;,]/', $description); //explode(';', $description);
+
+ // Process each part of the description
+ foreach ($parts as $part) {
+ $part = trim($part);
+
+ // Check if the part contains a key-value structure
+ if (str_contains($part, ':')) {
+ [$name, $value] = explode(':', $part, 2);
+ $name = trim($name);
+ $value = trim($value);
+
+ // Attempt to parse the value, handling ranges, units, and additional information
+ $parsedValue = $this->customParseValueIncludingUnit($name, $value);
+
+ // If the value was successfully parsed, create a ParameterDTO
+ if ($parsedValue) {
+ // Convert numeric values to float
+ $value_typ = isset($parsedValue['value_typ']) ? (float)$parsedValue['value_typ'] : null;
+ $value_min = isset($parsedValue['value_min']) ? (float)$parsedValue['value_min'] : null;
+ $value_max = isset($parsedValue['value_max']) ? (float)$parsedValue['value_max'] : null;
+
+ $parameters[] = new ParameterDTO(
+ name: $parsedValue['name'],
+ value_text: $parsedValue['value_text'] ?? null,
+ value_typ: $value_typ,
+ value_min: $value_min,
+ value_max: $value_max,
+ unit: $parsedValue['unit'] ?? null, // Add extracted unit
+ symbol: $parsedValue['symbol'] ?? null // Add extracted symbol
+ );
+ }
+ }
+ }
+
+ return !empty($parameters) ? $parameters : null;
+ }
+
+ /**
+ * Parses a value that may contain both a numerical value and its corresponding unit.
+ * This function splits the value into its numerical part and its unit, handling cases
+ * where the value includes symbols, ranges, or additional text. It also detects and
+ * processes plus/minus ranges, typical values, and other special formats.
+ *
+ * Example formats that can be handled:
+ * - "2.5V"
+ * - "±5%"
+ * - "1-10A"
+ * - "2.5 @text"
+ * - "~100 Ohm"
+ *
+ * @param string $value The value string to be parsed, which may contain a number, unit, or both.
+ *
+ * @return array An associative array with parsed components:
+ * - 'name' => string (the name of the parameter)
+ * - 'value_typ' => float|null (the typical or parsed value)
+ * - 'range_min' => float|null (the minimum value if it's a range)
+ * - 'range_max' => float|null (the maximum value if it's a range)
+ * - 'value_text' => string|null (any additional text or symbol)
+ * - 'unit' => string|null (the detected or default unit)
+ * - 'symbol' => string|null (any special symbol or additional text)
+ */
+ private function customParseValueIncludingUnit(string $name, string $value): array
+ {
+ // Parse using logic for units, ranges, and other elements
+ $result = [
+ 'name' => $name,
+ 'value_typ' => null,
+ 'value_min' => null,
+ 'value_max' => null,
+ 'value_text' => null,
+ 'unit' => null,
+ 'symbol' => null,
+ ];
+
+ // Trim any whitespace from the value
+ $value = trim($value);
+
+ // Handle ranges and plus/minus signs
+ if (str_contains($value, '...') || str_contains($value, '~') || str_contains($value, '±')) {
+ // Handle ranges
+ $value = str_replace(['...', '~'], '...', $value); // Normalize range separators
+ $rangeParts = preg_split('/\s*[\.\~]\s*/', $value);
+
+ if (count($rangeParts) === 2) {
+ // Splitting the values and units
+ $parsedMin = $this->customSplitIntoValueAndUnit($rangeParts[0]);
+ $parsedMax = $this->customSplitIntoValueAndUnit($rangeParts[1]);
+
+ // Assigning the parsed values
+ $result['value_min'] = $parsedMin['value_typ'];
+ $result['value_max'] = $parsedMax['value_typ'];
+
+ // Determine the unit
+ $result['unit'] = $parsedMax['unit'] ?? $parsedMin['unit'];
+ }
+
+ } elseif (str_contains($value, '@')) {
+ // If we find "@", we treat it as additional textual information
+ [$numericValue, $textValue] = explode('@', $value);
+ $result['value_typ'] = (float) $numericValue;
+ $result['value_text'] = trim($textValue);
+ } else {
+ // Check if the value is numeric with a unit
+ if (preg_match('/^([\+\-]?\d+(\.\d+)?)([a-zA-Z%°]+)?$/u', $value, $matches)) {
+ // It is a number with or without a unit
+ $result['value_typ'] = (float) $matches[1];
+ $result['unit'] = $matches[3] ?? null;
+ } else {
+ // It's not a number, so we treat it as text
+ $result['value_text'] = $value;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Splits a string into a numerical value and its associated unit. The function attempts to separate
+ * a number from its unit, handling common formats where the unit follows the number (e.g., "50kHz", "10A").
+ * The function assumes the unit is the non-numeric part of the string.
+ *
+ * Example formats that can be handled:
+ * - "100 Ohm"
+ * - "10 MHz"
+ * - "5kV"
+ * - "±5%"
+ *
+ * @param string $value1 The input string containing both a numerical value and a unit.
+ * @param string|null $value2 Optional. A second value string, typically used for ranges (e.g., "10-20A").
+ *
+ * @return array An associative array with the following elements:
+ * - 'value_typ' => string|null The first numerical part of the string.
+ * - 'unit' => string|null The unit part of the string, or null if no unit is detected.
+ * - 'value_min' => string|null The minimum value in a range, if applicable.
+ * - 'value_max' => string|null The maximum value in a range, if applicable.
+ */
+ private function customSplitIntoValueAndUnit(string $value1, ?string $value2 = null): array
+ {
+ // Separate numbers and units (basic parsing handling)
+ $unit = null;
+ $value_typ = null;
+
+ // Search for the number + unit pattern
+ if (preg_match('/^([\+\-]?\d+(\.\d+)?)([a-zA-Z%°]+)?$/u', $value1, $matches)) {
+ $value_typ = $matches[1];
+ $unit = $matches[3] ?? null;
+ }
+
+ $result = [
+ 'value_typ' => $value_typ,
+ 'unit' => $unit,
+ ];
+
+ if ($value2 !== null) {
+ if (preg_match('/^([\+\-]?\d+(\.\d+)?)([a-zA-Z%°]+)?$/u', $value2, $matches2)) {
+ $result['value_min'] = $value_typ;
+ $result['value_max'] = $matches2[1];
+ $result['unit'] = $matches2[3] ?? $unit; // If both values have the same unit, we keep it
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Generates the API URL to fetch product information for the specified part number from OEMSecrets.
+ * Ensures that the base API URL and any query parameters are properly formatted.
+ *
+ * @param string $partNumber The part number to include in the URL.
+ * @param string $oemInquiry The inquiry path for the OEMSecrets API, with a default value of 'compare/'.
+ * This parameter represents the specific API endpoint to query.
+ *
+ * @return string The complete provider URL including the base provider URL, the inquiry path, and the part number.
+ *
+ * Example:
+ * If the base URL is "https://www.oemsecrets.com/", the inquiry path is "compare/", and the part number is "NE555",
+ * the resulting URL will be: "https://www.oemsecrets.com/compare/NE555"
+ */
+ private function generateInquiryUrl(string $partNumber, string $oemInquiry = 'compare/'): string
+ {
+ $baseUrl = rtrim($this->getProviderInfo()['url'], '/') . '/';
+ $inquiryPath = trim($oemInquiry, '/') . '/';
+ $encodedPartNumber = urlencode(trim($partNumber));
+ return $baseUrl . $inquiryPath . $encodedPartNumber;
+ }
+
+ /**
+ * Sorts the results data array based on the specified search keyword and sorting criteria.
+ * The sorting process involves multiple phases:
+ * 1. Exact match with the search keyword.
+ * 2. Prefix match with the search keyword.
+ * 3. Alphabetical order of the suffix following the keyword.
+ * 4. Optional sorting by completeness or manufacturer based on the sort criteria.
+ *
+ * The sorting criteria (`sort_criteria`) is an environment variable configured in the `.env.local` file:
+ * PROVIDER_OEMSECRETS_SORT_CRITERIA
+ * It determines the final sorting phase:
+ * - 'C': Sort by completeness.
+ * - 'M': Sort by manufacturer.
+ *
+ * @param array $resultsData The array of result objects to be sorted. Each object should have 'name' and 'manufacturer' properties.
+ * @param string $searchKeyword The search keyword used for sorting the results.
+ *
+ * @return void
+ */
+ private function sortResultsData(array &$resultsData, string $searchKeyword): void
+ {
+ // If the SORT_CRITERIA is not 'C' or 'M', do not sort
+ if ($this->sort_criteria !== 'C' && $this->sort_criteria !== 'M') {
+ return;
+ }
+ usort($resultsData, function ($a, $b) use ($searchKeyword) {
+ $nameA = trim($a->name);
+ $nameB = trim($b->name);
+
+ // First phase: Sorting by exact match with the keyword
+ $exactMatchA = strcasecmp($nameA, $searchKeyword) === 0;
+ $exactMatchB = strcasecmp($nameB, $searchKeyword) === 0;
+
+ if ($exactMatchA && !$exactMatchB) {
+ return -1;
+ } elseif (!$exactMatchA && $exactMatchB) {
+ return 1;
+ }
+
+ // Second phase: Sorting by prefix (name starting with the keyword)
+ $startsWithKeywordA = stripos($nameA, $searchKeyword) === 0;
+ $startsWithKeywordB = stripos($nameB, $searchKeyword) === 0;
+
+ if ($startsWithKeywordA && !$startsWithKeywordB) {
+ return -1;
+ } elseif (!$startsWithKeywordA && $startsWithKeywordB) {
+ return 1;
+ }
+
+ if ($startsWithKeywordA && $startsWithKeywordB) {
+ // Alphabetical sorting of suffixes
+ $suffixA = substr($nameA, strlen($searchKeyword));
+ $suffixB = substr($nameB, strlen($searchKeyword));
+ $suffixComparison = strcasecmp($suffixA, $suffixB);
+
+ if ($suffixComparison !== 0) {
+ return $suffixComparison;
+ }
+ }
+
+ // Final sorting: by completeness or manufacturer, if necessary
+ if ($this->sort_criteria === 'C') {
+ return $this->compareByCompleteness($a, $b);
+ } elseif ($this->sort_criteria === 'M') {
+ return strcasecmp($a->manufacturer, $b->manufacturer);
+ }
+
+ });
+ }
+
+ /**
+ * Compares two objects based on their "completeness" score.
+ * The completeness score is calculated by the `calculateCompleteness` method, which assigns a numeric score
+ * based on the amount of information (such as parameters, datasheets, images, etc.) available for each object.
+ * The comparison is done in descending order, giving priority to the objects with higher completeness.
+ *
+ * @param object $a The first object to compare.
+ * @param object $b The second object to compare.
+ *
+ * @return int A negative value if $b is more complete than $a, zero if they are equally complete,
+ * or a positive value if $a is more complete than $b.
+ */
+ private function compareByCompleteness(object $a, object $b): int
+ {
+ // Calculate the completeness score for each object
+ $completenessA = $this->calculateCompleteness($a);
+ $completenessB = $this->calculateCompleteness($b);
+
+ // Sort in descending order by completeness (higher score is better)
+ return $completenessB - $completenessA;
+ }
+
+
+ /**
+ * Calculates a "completeness" score for a given part object based on the presence and count of various attributes.
+ * The completeness score is used to prioritize parts that have more detailed information.
+ *
+ * The score is calculated as follows:
+ * - Counts the number of elements in the `parameters`, `datasheets`, `images`, and `vendor_infos` arrays.
+ * - Adds 1 point for the presence of `category`, `description`, `mpn`, `preview_image_url`, and `footprint`.
+ * - Adds 1 or 2 points based on the presence or absence of `manufacturing_status` (higher score if `null`).
+ *
+ * @param object $part The part object for which the completeness score is calculated. The object is expected
+ * to have properties like `parameters`, `datasheets`, `images`, `vendor_infos`, `category`,
+ * `description`, `mpn`, `preview_image_url`, `footprint`, and `manufacturing_status`.
+ *
+ * @return int The calculated completeness score, with a higher score indicating more complete information.
+ */
+ private function calculateCompleteness(object $part): int
+ {
+ // Counts the number of elements in each field that can have multiple values
+ $paramsCount = is_array($part->parameters) ? count($part->parameters) : 0;
+ $datasheetsCount = is_array($part->datasheets) ? count($part->datasheets) : 0;
+ $imagesCount = is_array($part->images) ? count($part->images) : 0;
+ $vendorInfosCount = is_array($part->vendor_infos) ? count($part->vendor_infos) : 0;
+
+ // Check for the presence of single fields and assign a score
+ $categoryScore = !empty($part->category) ? 1 : 0;
+ $descriptionScore = !empty($part->description) ? 1 : 0;
+ $mpnScore = !empty($part->mpn) ? 1 : 0;
+ $previewImageScore = !empty($part->preview_image_url) ? 1 : 0;
+ $footprintScore = !empty($part->footprint) ? 1 : 0;
+
+ // Weight for manufacturing_status: higher if null
+ $manufacturingStatusScore = is_null($part->manufacturing_status) ? 2 : 1;
+
+ // Sum the counts and scores to obtain a completeness score
+ return $paramsCount
+ + $datasheetsCount
+ + $imagesCount
+ + $vendorInfosCount
+ + $categoryScore
+ + $descriptionScore
+ + $mpnScore
+ + $previewImageScore
+ + $footprintScore
+ + $manufacturingStatusScore;
+ }
+
+
+ /**
+ * Generates a unique provider ID by concatenating the part number and manufacturer name,
+ * separated by a pipe (`|`). The generated ID is typically used to uniquely identify
+ * a specific part from a particular manufacturer.
+ *
+ * @param string $partNumber The part number of the product.
+ * @param string $manufacturer The name of the manufacturer.
+ *
+ * @return string The generated provider ID, in the format "partNumber|manufacturer".
+ */
+ private function generateProviderId(string $partNumber, string $manufacturer): string
+ {
+ return trim($partNumber) . '|' . trim($manufacturer);
+ }
+
+ /**
+ * Maps the name of a country to its corresponding ISO 3166-1 alpha-2 code.
+ *
+ * @param string|null $countryName The name of the country to map.
+ * @return string|null The ISO code for the country, or null if not found.
+ */
+ private function mapCountryNameToCode(?string $countryName): ?string
+ {
+ return $this->countryNameToCodeMap[$countryName] ?? null;
+ }
+
+ /**
+ * Removes the analytics tracking parts from the URLs returned by the API.
+ *
+ * @param string|null $url
+ * @return string|null
+ */
+ private function unwrapURL(?string $url): ?string
+ {
+ if ($url === null) {
+ return null;
+ }
+
+ //Check if the URL is a one redirected via analytics
+ if (str_contains($url, 'analytics.oemsecrets.com/main.php')) {
+ //Extract the URL from the analytics URL
+ $queryParams = [];
+ parse_str(parse_url($url, PHP_URL_QUERY), $queryParams);
+
+ //The real URL is stored in the 'event_link' query parameter
+ if (isset($queryParams['event_link']) && trim($queryParams['event_link']) !== '') {
+ $url = $queryParams['event_link'];
+
+ //Replace any spaces in the URL by %20 to avoid invalid URLs
+ return str_replace(' ', '%20', $url);
+ }
+ }
+
+ //Otherwise return the URL as it is
+ return $url;
+ }
+
+}
\ No newline at end of file
diff --git a/src/Services/InfoProviderSystem/Providers/OctopartProvider.php b/src/Services/InfoProviderSystem/Providers/OctopartProvider.php
new file mode 100644
index 00000000..e28162ba
--- /dev/null
+++ b/src/Services/InfoProviderSystem/Providers/OctopartProvider.php
@@ -0,0 +1,406 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\InfoProviderSystem\Providers;
+
+use App\Entity\Parts\ManufacturingStatus;
+use App\Services\InfoProviderSystem\DTOs\FileDTO;
+use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
+use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
+use App\Services\InfoProviderSystem\DTOs\PriceDTO;
+use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
+use App\Services\OAuth\OAuthTokenManager;
+use Psr\Cache\CacheItemPoolInterface;
+use Symfony\Component\HttpClient\HttpOptions;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+
+/**
+ * This class implements the Octopart/Nexar API as an InfoProvider
+ *
+ * As the limits for Octopart are quite limited, we use an additional layer of caching here, we get the full parts during a search
+ * and cache them, so we can use them for the detail view without having to query the API again.
+ */
+class OctopartProvider implements InfoProviderInterface
+{
+ private const OAUTH_APP_NAME = 'ip_octopart_oauth';
+
+ /**
+ * This defines what fields are returned in the answer from the Octopart API
+ */
+ private const GRAPHQL_PART_SECTION = <<<'GRAPHQL'
+ {
+ id
+ mpn
+ octopartUrl
+ manufacturer {
+ name
+ }
+ shortDescription
+ category {
+ ancestors {
+ name
+ }
+ name
+ }
+ bestImage {
+ url
+ }
+ bestDatasheet {
+ url
+ name
+ }
+ manufacturerUrl
+ medianPrice1000 {
+ price
+ currency
+ quantity
+ }
+ sellers(authorizedOnly: $authorizedOnly) {
+ company {
+ name
+ }
+ isAuthorized
+ offers {
+ clickUrl
+ inventoryLevel
+ moq
+ sku
+ packaging
+ prices {
+ price
+ currency
+ quantity
+ }
+ }
+ },
+ specs {
+ attribute {
+ name
+ shortname
+ group
+ id
+ }
+ displayValue
+ value
+ siValue
+ units
+ unitsName
+ unitsSymbol
+ valueType
+ }
+ }
+ GRAPHQL;
+
+
+ public function __construct(private readonly HttpClientInterface $httpClient,
+ private readonly OAuthTokenManager $authTokenManager, private readonly CacheItemPoolInterface $partInfoCache,
+ private readonly string $clientId, private readonly string $secret,
+ private readonly string $currency, private readonly string $country,
+ private readonly int $search_limit, private readonly bool $onlyAuthorizedSellers)
+ {
+
+ }
+
+ /**
+ * Gets the latest OAuth token for the Octopart API, or creates a new one if none is available
+ * @return string
+ */
+ private function getToken(): string
+ {
+ //Check if we already have a token saved for this app, otherwise we have to retrieve one via OAuth
+ if (!$this->authTokenManager->hasToken(self::OAUTH_APP_NAME)) {
+ $this->authTokenManager->retrieveClientCredentialsToken(self::OAUTH_APP_NAME);
+ }
+
+ $tmp = $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME);
+ if ($tmp === null) {
+ throw new \RuntimeException('Could not retrieve OAuth token for Octopart');
+ }
+
+ return $tmp;
+ }
+
+ /**
+ * Make a GraphQL call to the Octopart API
+ * @return array
+ */
+ private function makeGraphQLCall(string $query, ?array $variables = null): array
+ {
+ if ($variables === []) {
+ $variables = null;
+ }
+
+ $options = (new HttpOptions())
+ ->setJson(['query' => $query, 'variables' => $variables])
+ ->setAuthBearer($this->getToken())
+ ;
+
+ $response = $this->httpClient->request(
+ 'POST',
+ 'https://api.nexar.com/graphql/',
+ $options->toArray(),
+ );
+
+ return $response->toArray(true);
+ }
+
+ public function getProviderInfo(): array
+ {
+ return [
+ 'name' => 'Octopart',
+ 'description' => 'This provider uses the Nexar/Octopart API to search for parts on Octopart.',
+ 'url' => 'https://www.octopart.com/',
+ 'disabled_help' => 'Set the PROVIDER_OCTOPART_CLIENT_ID and PROVIDER_OCTOPART_SECRET env option.'
+ ];
+ }
+
+ public function getProviderKey(): string
+ {
+ return 'octopart';
+ }
+
+ public function isActive(): bool
+ {
+ //The client ID has to be set and a token has to be available (user clicked connect)
+ //return /*!empty($this->clientId) && */ $this->authTokenManager->hasToken(self::OAUTH_APP_NAME);
+ return $this->clientId !== '' && $this->secret !== '';
+ }
+
+ private function mapLifeCycleStatus(?string $value): ?ManufacturingStatus
+ {
+ return match ($value) {
+ 'Production', 'New' => ManufacturingStatus::ACTIVE,
+ 'Obsolete' => ManufacturingStatus::DISCONTINUED,
+ 'NRND' => ManufacturingStatus::NRFND,
+ 'EOL' => ManufacturingStatus::EOL,
+ default => null,
+ };
+ }
+
+ /**
+ * Saves the given part to the cache.
+ * Everytime this function is called, the cache is overwritten.
+ * @param PartDetailDTO $part
+ * @return void
+ */
+ private function saveToCache(PartDetailDTO $part): void
+ {
+ $key = 'octopart_part_'.$part->provider_id;
+
+ $item = $this->partInfoCache->getItem($key);
+ $item->set($part);
+ $item->expiresAfter(3600 * 24); //Cache for 1 day
+ $this->partInfoCache->save($item);
+ }
+
+ /**
+ * Retrieves a from the cache, or null if it was not cached yet.
+ * @param string $id
+ * @return PartDetailDTO|null
+ */
+ private function getFromCache(string $id): ?PartDetailDTO
+ {
+ $key = 'octopart_part_'.$id;
+
+ $item = $this->partInfoCache->getItem($key);
+ if ($item->isHit()) {
+ return $item->get();
+ }
+
+ return null;
+ }
+
+ private function partResultToDTO(array $part): PartDetailDTO
+ {
+ //Parse the specifications
+ $parameters = [];
+ $mass = null;
+ $package = null;
+ $pinCount = null;
+ $mStatus = null;
+ foreach ($part['specs'] as $spec) {
+
+ //If we encounter the mass spec, we save it for later
+ if ($spec['attribute']['shortname'] === "weight") {
+ $mass = (float) $spec['siValue'];
+ } elseif ($spec['attribute']['shortname'] === "case_package") {
+ //Package
+ $package = $spec['value'];
+ } elseif ($spec['attribute']['shortname'] === "numberofpins") {
+ //Pin Count
+ $pinCount = $spec['value'];
+ } elseif ($spec['attribute']['shortname'] === "lifecyclestatus") {
+ //LifeCycleStatus
+ $mStatus = $this->mapLifeCycleStatus($spec['value']);
+ }
+
+ $parameters[] = new ParameterDTO(
+ name: $spec['attribute']['name'],
+ value_text: $spec['valueType'] === 'text' ? $spec['value'] : null,
+ value_typ: in_array($spec['valueType'], ['float', 'integer'], true) ? (float) $spec['value'] : null,
+ unit: $spec['valueType'] === 'text' ? null : $spec['units'],
+ group: $spec['attribute']['group'],
+ );
+ }
+
+ //Parse the offers
+ $orderinfos = [];
+ foreach ($part['sellers'] as $seller) {
+ foreach ($seller['offers'] as $offer) {
+ $prices = [];
+ foreach ($offer['prices'] as $price) {
+ $prices[] = new PriceDTO(
+ minimum_discount_amount: $price['quantity'],
+ price: (string) $price['price'],
+ currency_iso_code: $price['currency'],
+ );
+ }
+
+ $orderinfos[] = new PurchaseInfoDTO(
+ distributor_name: $seller['company']['name'],
+ order_number: $offer['sku'],
+ prices: $prices,
+ product_url: $offer['clickUrl'],
+ );
+ }
+ }
+
+ //Generate a footprint name from the package and pin count
+ $footprint = null;
+ if ($package !== null) {
+ $footprint = $package;
+ if ($pinCount !== null) { //Add pin count if available
+ $footprint .= '-' . $pinCount;
+ }
+ }
+
+ //Built the category full path
+ $category = null;
+ if (!empty($part['category']['name'])) {
+ $category = implode(' -> ', array_map(static fn($c) => $c['name'], $part['category']['ancestors'] ?? []));
+ if ($category !== '' && $category !== '0') {
+ $category .= ' -> ';
+ }
+ $category .= $part['category']['name'];
+ }
+
+ return new PartDetailDTO(
+ provider_key: $this->getProviderKey(),
+ provider_id: $part['id'],
+ name: $part['mpn'],
+ description: $part['shortDescription'] ?? null,
+ category: $category ,
+ manufacturer: $part['manufacturer']['name'] ?? null,
+ mpn: $part['mpn'],
+ preview_image_url: $part['bestImage']['url'] ?? null,
+ manufacturing_status: $mStatus,
+ provider_url: $part['octopartUrl'] ?? null,
+ footprint: $footprint,
+ datasheets: $part['bestDatasheet'] !== null ? [new FileDTO($part['bestDatasheet']['url'], $part['bestDatasheet']['name'])]: null,
+ parameters: $parameters,
+ vendor_infos: $orderinfos,
+ mass: $mass,
+ manufacturer_product_url: $part['manufacturerUrl'] ?? null,
+ );
+ }
+
+ public function searchByKeyword(string $keyword): array
+ {
+ $graphQL = sprintf(<<<'GRAPHQL'
+ query partSearch($keyword: String, $limit: Int, $currency: String!, $country: String!, $authorizedOnly: Boolean!) {
+ supSearch(
+ q: $keyword
+ inStockOnly: false
+ limit: $limit
+ currency: $currency
+ country: $country
+ ) {
+ hits
+ results {
+ part
+ %s
+ }
+ }
+ }
+ GRAPHQL, self::GRAPHQL_PART_SECTION);
+
+
+ $result = $this->makeGraphQLCall($graphQL, [
+ 'keyword' => $keyword,
+ 'limit' => $this->search_limit,
+ 'currency' => $this->currency,
+ 'country' => $this->country,
+ 'authorizedOnly' => $this->onlyAuthorizedSellers,
+ ]);
+
+ $tmp = [];
+
+ foreach ($result['data']['supSearch']['results'] ?? [] as $p) {
+ $dto = $this->partResultToDTO($p['part']);
+ $tmp[] = $dto;
+ //Cache the part, so we can get the details later, without having to make another request
+ $this->saveToCache($dto);
+ }
+
+ return $tmp;
+ }
+
+ public function getDetails(string $id): PartDetailDTO
+ {
+ //Check if we have the part cached
+ $cached = $this->getFromCache($id);
+ if ($cached !== null) {
+ return $cached;
+ }
+
+ //Otherwise we have to make a request
+ $graphql = sprintf(<<<'GRAPHQL'
+ query partSearch($ids: [String!]!, $currency: String!, $country: String!, $authorizedOnly: Boolean!) {
+ supParts(ids: $ids, currency: $currency, country: $country)
+ %s
+ }
+ GRAPHQL, self::GRAPHQL_PART_SECTION);
+
+ $result = $this->makeGraphQLCall($graphql, [
+ 'ids' => [$id],
+ 'currency' => $this->currency,
+ 'country' => $this->country,
+ 'authorizedOnly' => $this->onlyAuthorizedSellers,
+ ]);
+
+ $tmp = $this->partResultToDTO($result['data']['supParts'][0]);
+ $this->saveToCache($tmp);
+ return $tmp;
+ }
+
+ public function getCapabilities(): array
+ {
+ return [
+ ProviderCapabilities::BASIC,
+ ProviderCapabilities::FOOTPRINT,
+ ProviderCapabilities::PICTURE,
+ ProviderCapabilities::DATASHEET,
+ ProviderCapabilities::PRICE,
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/Services/InfoProviderSystem/Providers/PollinProvider.php b/src/Services/InfoProviderSystem/Providers/PollinProvider.php
new file mode 100644
index 00000000..09ab8fd4
--- /dev/null
+++ b/src/Services/InfoProviderSystem/Providers/PollinProvider.php
@@ -0,0 +1,249 @@
+.
+ */
+
+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\Component\DependencyInjection\Attribute\Autowire;
+use Symfony\Component\DomCrawler\Crawler;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+
+class PollinProvider implements InfoProviderInterface
+{
+
+ public function __construct(private readonly HttpClientInterface $client,
+ #[Autowire(env: 'bool:PROVIDER_POLLIN_ENABLED')]
+ private readonly bool $enabled = true,
+ )
+ {
+ }
+
+ public function getProviderInfo(): array
+ {
+ return [
+ 'name' => 'Pollin',
+ 'description' => 'Webscraping from pollin.de to get part information',
+ 'url' => 'https://www.pollin.de/',
+ 'disabled_help' => 'Set PROVIDER_POLLIN_ENABLED env to 1'
+ ];
+ }
+
+ public function getProviderKey(): string
+ {
+ return 'pollin';
+ }
+
+ public function isActive(): bool
+ {
+ return $this->enabled;
+ }
+
+ public function searchByKeyword(string $keyword): array
+ {
+ $response = $this->client->request('GET', 'https://www.pollin.de/search', [
+ 'query' => [
+ 'search' => $keyword
+ ]
+ ]);
+
+ $content = $response->getContent();
+
+ //If the response has us redirected to the product page, then just return the single item
+ if ($response->getInfo('redirect_count') > 0) {
+ return [$this->parseProductPage($content)];
+ }
+
+ $dom = new Crawler($content);
+
+ $results = [];
+
+ //Iterate over each div.product-box
+ $dom->filter('div.product-box')->each(function (Crawler $node) use (&$results) {
+ $results[] = new SearchResultDTO(
+ provider_key: $this->getProviderKey(),
+ provider_id: $node->filter('meta[itemprop="productID"]')->attr('content'),
+ name: $node->filter('a.product-name')->text(),
+ description: '',
+ preview_image_url: $node->filter('img.product-image')->attr('src'),
+ manufacturing_status: $this->mapAvailability($node->filter('link[itemprop="availability"]')->attr('href')),
+ provider_url: $node->filter('a.product-name')->attr('href')
+ );
+ });
+
+ return $results;
+ }
+
+ private function mapAvailability(string $availabilityURI): ManufacturingStatus
+ {
+ return match( $availabilityURI) {
+ 'http://schema.org/InStock' => ManufacturingStatus::ACTIVE,
+ 'http://schema.org/OutOfStock' => ManufacturingStatus::DISCONTINUED,
+ default => ManufacturingStatus::NOT_SET
+ };
+ }
+
+ public function getDetails(string $id): PartDetailDTO
+ {
+ //Ensure that $id is numeric
+ if (!is_numeric($id)) {
+ throw new \InvalidArgumentException("The id must be numeric!");
+ }
+
+ $response = $this->client->request('GET', 'https://www.pollin.de/search', [
+ 'query' => [
+ 'search' => $id
+ ]
+ ]);
+
+ //The response must have us redirected to the product page
+ if ($response->getInfo('redirect_count') > 0) {
+ throw new \RuntimeException("Could not resolve the product page for the given id!");
+ }
+
+ $content = $response->getContent();
+
+ return $this->parseProductPage($content);
+ }
+
+ private function parseProductPage(string $content): PartDetailDTO
+ {
+ $dom = new Crawler($content);
+
+ $productPageUrl = $dom->filter('meta[property="product:product_link"]')->attr('content');
+ $orderId = trim($dom->filter('span[itemprop="sku"]')->text()); //Text is important here
+
+ //Calculate the mass
+ $massStr = $dom->filter('meta[itemprop="weight"]')->attr('content');
+ //Remove the unit
+ $massStr = str_replace('kg', '', $massStr);
+ //Convert to float and convert to grams
+ $mass = (float) $massStr * 1000;
+
+ //Parse purchase info
+ $purchaseInfo = new PurchaseInfoDTO('Pollin', $orderId, $this->parsePrices($dom), $productPageUrl);
+
+ return new PartDetailDTO(
+ provider_key: $this->getProviderKey(),
+ provider_id: $orderId,
+ name: trim($dom->filter('meta[property="og:title"]')->attr('content')),
+ description: $dom->filter('meta[property="og:description"]')->attr('content'),
+ category: $this->parseCategory($dom),
+ manufacturer: $dom->filter('meta[property="product:brand"]')->count() > 0 ? $dom->filter('meta[property="product:brand"]')->attr('content') : null,
+ preview_image_url: $dom->filter('meta[property="og:image"]')->attr('content'),
+ manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')),
+ provider_url: $productPageUrl,
+ notes: $this->parseNotes($dom),
+ datasheets: $this->parseDatasheets($dom),
+ parameters: $this->parseParameters($dom),
+ vendor_infos: [$purchaseInfo],
+ mass: $mass,
+ );
+ }
+
+ private function parseDatasheets(Crawler $dom): array
+ {
+ //Iterate over each a element withing div.pol-product-detail-download-files
+ $datasheets = [];
+ $dom->filter('div.pol-product-detail-download-files a')->each(function (Crawler $node) use (&$datasheets) {
+ $datasheets[] = new FileDTO($node->attr('href'), $node->text());
+ });
+
+ return $datasheets;
+ }
+
+ private function parseParameters(Crawler $dom): array
+ {
+ $parameters = [];
+
+ //Iterate over each tr.properties-row inside table.product-detail-properties-table
+ $dom->filter('table.product-detail-properties-table tr.properties-row')->each(function (Crawler $node) use (&$parameters) {
+ $parameters[] = ParameterDTO::parseValueIncludingUnit(
+ name: rtrim($node->filter('th.properties-label')->text(), ':'),
+ value: trim($node->filter('td.properties-value')->text())
+ );
+ });
+
+ return $parameters;
+ }
+
+ private function parseCategory(Crawler $dom): string
+ {
+ $category = '';
+
+ //Iterate over each li.breadcrumb-item inside ol.breadcrumb
+ $dom->filter('ol.breadcrumb li.breadcrumb-item')->each(function (Crawler $node) use (&$category) {
+ //Skip if it has breadcrumb-item-home class
+ if (str_contains($node->attr('class'), 'breadcrumb-item-home')) {
+ return;
+ }
+
+
+ $category .= $node->text() . ' -> ';
+ });
+
+ //Remove the last ' -> '
+ return substr($category, 0, -4);
+ }
+
+ private function parseNotes(Crawler $dom): string
+ {
+ //Concat product highlights and product description
+ return $dom->filter('div.product-detail-top-features')->html('') . '
' . $dom->filter('div.product-detail-description-text')->html('');
+ }
+
+ private function parsePrices(Crawler $dom): array
+ {
+ //TODO: Properly handle multiple prices, for now we just look at the price for one piece
+
+ //We assume the currency is always the same
+ $currency = $dom->filter('meta[property="product:price:currency"]')->attr('content');
+
+ //If there is meta[property=highPrice] then use this as the price
+ if ($dom->filter('meta[itemprop="highPrice"]')->count() > 0) {
+ $price = $dom->filter('meta[itemprop="highPrice"]')->attr('content');
+ } else {
+ $price = $dom->filter('meta[property="product:price:amount"]')->attr('content');
+ }
+
+ return [
+ new PriceDTO(1.0, $price, $currency)
+ ];
+ }
+
+ public function getCapabilities(): array
+ {
+ return [
+ ProviderCapabilities::BASIC,
+ ProviderCapabilities::PICTURE,
+ ProviderCapabilities::PRICE,
+ ProviderCapabilities::DATASHEET
+ ];
+ }
+}
\ 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/ReicheltProvider.php b/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php
new file mode 100644
index 00000000..0c31c411
--- /dev/null
+++ b/src/Services/InfoProviderSystem/Providers/ReicheltProvider.php
@@ -0,0 +1,285 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\InfoProviderSystem\Providers;
+
+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\Component\DependencyInjection\Attribute\Autowire;
+use Symfony\Component\DomCrawler\Crawler;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+
+class ReicheltProvider implements InfoProviderInterface
+{
+
+ public const DISTRIBUTOR_NAME = "Reichelt";
+
+ public function __construct(private readonly HttpClientInterface $client,
+ #[Autowire(env: "bool:PROVIDER_REICHELT_ENABLED")]
+ private readonly bool $enabled = true,
+ #[Autowire(env: "PROVIDER_REICHELT_LANGUAGE")]
+ private readonly string $language = "en",
+ #[Autowire(env: "PROVIDER_REICHELT_COUNTRY")]
+ private readonly string $country = "DE",
+ #[Autowire(env: "PROVIDER_REICHELT_INCLUDE_VAT")]
+ private readonly bool $includeVAT = false,
+ #[Autowire(env: "PROVIDER_REICHELT_CURRENCY")]
+ private readonly string $currency = "EUR",
+ )
+ {
+ }
+
+ public function getProviderInfo(): array
+ {
+ return [
+ 'name' => 'Reichelt',
+ 'description' => 'Webscraping from reichelt.com to get part information',
+ 'url' => 'https://www.reichelt.com/',
+ 'disabled_help' => 'Set PROVIDER_REICHELT_ENABLED env to 1'
+ ];
+ }
+
+ public function getProviderKey(): string
+ {
+ return 'reichelt';
+ }
+
+ public function isActive(): bool
+ {
+ return $this->enabled;
+ }
+
+ public function searchByKeyword(string $keyword): array
+ {
+ $response = $this->client->request('GET', sprintf($this->getBaseURL() . '/shop/search/%s', $keyword));
+ $html = $response->getContent();
+
+ //Parse the HTML and return the results
+ $dom = new Crawler($html);
+ //Iterate over all div.al_gallery_article elements
+ $results = [];
+ $dom->filter('div.al_gallery_article')->each(function (Crawler $element) use (&$results) {
+
+ //Extract product id from data-product attribute
+ $artId = json_decode($element->attr('data-product'), true, 2, JSON_THROW_ON_ERROR)['artid'];
+
+ $productID = $element->filter('meta[itemprop="productID"]')->attr('content');
+ $name = $element->filter('meta[itemprop="name"]')->attr('content');
+ $sku = $element->filter('meta[itemprop="sku"]')->attr('content');
+
+ //Try to extract a picture URL:
+ $pictureURL = $element->filter("div.al_artlogo img")->attr('src');
+
+ $results[] = new SearchResultDTO(
+ provider_key: $this->getProviderKey(),
+ provider_id: $artId,
+ name: $productID,
+ description: $name,
+ category: null,
+ manufacturer: $sku,
+ preview_image_url: $pictureURL,
+ provider_url: $element->filter('a.al_artinfo_link')->attr('href')
+ );
+ });
+
+ return $results;
+ }
+
+ public function getDetails(string $id): PartDetailDTO
+ {
+ //Check that the ID is a number
+ if (!is_numeric($id)) {
+ throw new \InvalidArgumentException("Invalid ID");
+ }
+
+ //Use this endpoint to resolve the artID to a product page
+ $response = $this->client->request('GET',
+ sprintf(
+ 'https://www.reichelt.com/?ACTION=514&id=74&article=%s&LANGUAGE=%s&CCOUNTRY=%s',
+ $id,
+ strtoupper($this->language),
+ strtoupper($this->country)
+ )
+ );
+ $json = $response->toArray();
+
+ //Retrieve the product page from the response
+ $productPage = $this->getBaseURL() . '/shop/product' . $json[0]['article_path'];
+
+
+ $response = $this->client->request('GET', $productPage, [
+ 'query' => [
+ 'CCTYPE' => $this->includeVAT ? 'private' : 'business',
+ 'currency' => $this->currency,
+ ],
+ ]);
+ $html = $response->getContent();
+ $dom = new Crawler($html);
+
+ //Extract the product notes
+ $notes = $dom->filter('p[itemprop="description"]')->html();
+
+ //Extract datasheets
+ $datasheets = [];
+ $dom->filter('div.articleDatasheet a')->each(function (Crawler $element) use (&$datasheets) {
+ $datasheets[] = new FileDTO($element->attr('href'), $element->filter('span')->text());
+ });
+
+ //Determine price for one unit
+ $priceString = $dom->filter('meta[itemprop="price"]')->attr('content');
+ $currency = $dom->filter('meta[itemprop="priceCurrency"]')->attr('content', 'EUR');
+
+ //Create purchase info
+ $purchaseInfo = new PurchaseInfoDTO(
+ distributor_name: self::DISTRIBUTOR_NAME,
+ order_number: $json[0]['article_artnr'],
+ prices: array_merge(
+ [new PriceDTO(1.0, $priceString, $currency, $this->includeVAT)]
+ , $this->parseBatchPrices($dom, $currency)),
+ product_url: $productPage
+ );
+
+ //Create part object
+ return new PartDetailDTO(
+ provider_key: $this->getProviderKey(),
+ provider_id: $id,
+ name: $json[0]['article_artnr'],
+ description: $json[0]['article_besch'],
+ category: $this->parseCategory($dom),
+ manufacturer: $json[0]['manufacturer_name'],
+ mpn: $this->parseMPN($dom),
+ preview_image_url: $json[0]['article_picture'],
+ provider_url: $productPage,
+ notes: $notes,
+ datasheets: $datasheets,
+ parameters: $this->parseParameters($dom),
+ vendor_infos: [$purchaseInfo]
+ );
+
+ }
+
+ private function parseMPN(Crawler $dom): string
+ {
+ //Find the small element directly after meta[itemprop="url"] element
+ $element = $dom->filter('meta[itemprop="url"] + small');
+ //If the text contains GTIN text, take the small element afterwards
+ if (str_contains($element->text(), 'GTIN')) {
+ $element = $dom->filter('meta[itemprop="url"] + small + small');
+ }
+
+ //The MPN is contained in the span inside the element
+ return $element->filter('span')->text();
+ }
+
+ private function parseBatchPrices(Crawler $dom, string $currency): array
+ {
+ //Iterate over each a.inline-block element in div.discountValue
+ $prices = [];
+ $dom->filter('div.discountValue a.inline-block')->each(function (Crawler $element) use (&$prices, $currency) {
+ //The minimum amount is the number in the span.block element
+ $minAmountText = $element->filter('span.block')->text();
+
+ //Extract a integer from the text
+ $matches = [];
+ if (!preg_match('/\d+/', $minAmountText, $matches)) {
+ return;
+ }
+
+ $minAmount = (int) $matches[0];
+
+ //The price is the text of the p.productPrice element
+ $priceString = $element->filter('p.productPrice')->text();
+ //Replace comma with dot
+ $priceString = str_replace(',', '.', $priceString);
+ //Strip any non-numeric characters
+ $priceString = preg_replace('/[^0-9.]/', '', $priceString);
+
+ $prices[] = new PriceDTO($minAmount, $priceString, $currency, $this->includeVAT);
+ });
+
+ return $prices;
+ }
+
+
+ private function parseCategory(Crawler $dom): string
+ {
+ // Look for ol.breadcrumb and iterate over the li elements
+ $category = '';
+ $dom->filter('ol.breadcrumb li.triangle-left')->each(function (Crawler $element) use (&$category) {
+ //Do not include the .breadcrumb-showmore element
+ if ($element->attr('id') === 'breadcrumb-showmore') {
+ return;
+ }
+
+ $category .= $element->text() . ' -> ';
+ });
+ //Remove the trailing ' -> '
+ $category = substr($category, 0, -4);
+
+ return $category;
+ }
+
+ /**
+ * @param Crawler $dom
+ * @return ParameterDTO[]
+ */
+ private function parseParameters(Crawler $dom): array
+ {
+ $parameters = [];
+ //Iterate over each ul.articleTechnicalData which contains the specifications of each group
+ $dom->filter('ul.articleTechnicalData')->each(function (Crawler $groupElement) use (&$parameters) {
+ $groupName = $groupElement->filter('li.articleTechnicalHeadline')->text();
+
+ //Iterate over each second li in ul.articleAttribute, which contains the specifications
+ $groupElement->filter('ul.articleAttribute li:nth-child(2n)')->each(function (Crawler $specElement) use (&$parameters, $groupName) {
+ $parameters[] = ParameterDTO::parseValueIncludingUnit(
+ name: $specElement->previousAll()->text(),
+ value: $specElement->text(),
+ group: $groupName
+ );
+ });
+ });
+
+ return $parameters;
+ }
+
+ private function getBaseURL(): string
+ {
+ //Without the trailing slash
+ return 'https://www.reichelt.com/' . strtolower($this->country) . '/' . strtolower($this->language);
+ }
+
+ public function getCapabilities(): array
+ {
+ return [
+ ProviderCapabilities::BASIC,
+ ProviderCapabilities::PICTURE,
+ ProviderCapabilities::DATASHEET,
+ ProviderCapabilities::PRICE,
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/Services/InfoProviderSystem/Providers/TMEClient.php b/src/Services/InfoProviderSystem/Providers/TMEClient.php
new file mode 100644
index 00000000..d4df133e
--- /dev/null
+++ b/src/Services/InfoProviderSystem/Providers/TMEClient.php
@@ -0,0 +1,96 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\InfoProviderSystem\Providers;
+
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+
+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
+ {
+ return $this->token !== '' && $this->secret !== '';
+ }
+
+ /**
+ * Returns true if the client is using a private (account related token) instead of a deprecated anonymous token
+ * to authenticate with TME.
+ * @return bool
+ */
+ public function isUsingPrivateToken(): bool
+ {
+ //Private tokens are longer than anonymous ones (50 instead of 45 characters)
+ return strlen($this->token) > 45;
+ }
+
+ /**
+ * 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..32fc0c72
--- /dev/null
+++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php
@@ -0,0 +1,301 @@
+.
+ */
+
+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;
+
+class TMEProvider implements InfoProviderInterface
+{
+
+ private const VENDOR_NAME = 'TME';
+
+ /** @var bool If true, the prices are gross prices. If false, the prices are net prices. */
+ private readonly bool $get_gross_prices;
+
+ public function __construct(private readonly TMEClient $tmeClient, private readonly string $country,
+ private readonly string $language, private readonly string $currency,
+ bool $get_gross_prices)
+ {
+ //If we have a private token, set get_gross_prices to false, as it is automatically determined by the account type then
+ if ($this->tmeClient->isUsingPrivateToken()) {
+ $this->get_gross_prices = false;
+ } else {
+ $this->get_gross_prices = $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['Symbol'] : $product['OriginalSymbol'],
+ 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['Symbol'] : $product['OriginalSymbol'],
+ 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/BarcodeGenerator.php b/src/Services/LabelSystem/BarcodeGenerator.php
deleted file mode 100644
index a192abf2..00000000
--- a/src/Services/LabelSystem/BarcodeGenerator.php
+++ /dev/null
@@ -1,144 +0,0 @@
-.
- */
-
-declare(strict_types=1);
-
-/**
- * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
- *
- * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published
- * by the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-namespace App\Services\LabelSystem;
-
-use App\Entity\LabelSystem\LabelOptions;
-use App\Services\LabelSystem\Barcodes\BarcodeContentGenerator;
-use Com\Tecnick\Barcode\Barcode;
-use InvalidArgumentException;
-use PhpParser\Node\Stmt\Label;
-use Symfony\Component\Mime\MimeTypes;
-use Twig\Extra\Html\HtmlExtension;
-
-final class BarcodeGenerator
-{
- private BarcodeContentGenerator $barcodeContentGenerator;
-
-
- public function __construct(BarcodeContentGenerator $barcodeContentGenerator)
- {
- $this->barcodeContentGenerator = $barcodeContentGenerator;
- }
-
- public function generateHTMLBarcode(LabelOptions $options, object $target): ?string
- {
- $svg = $this->generateSVG($options, $target);
- $base64 = $this->dataUri($svg, 'image/svg+xml');
- return '';
- }
-
- /**
- * Creates a data URI (RFC 2397).
- * Based on the Twig implementaion from HTMLExtension
- *
- * Length validation is not performed on purpose, validation should
- * be done before calling this filter.
- *
- * @return string The generated data URI
- */
- private function dataUri(string $data, string $mime): string
- {
- $repr = 'data:';
-
- $repr .= $mime;
- if (0 === strpos($mime, 'text/')) {
- $repr .= ','.rawurlencode($data);
- } else {
- $repr .= ';base64,'.base64_encode($data);
- }
-
- return $repr;
- }
-
- public function generateSVG(LabelOptions $options, object $target): ?string
- {
- $barcode = new Barcode();
-
- switch ($options->getBarcodeType()) {
- case 'qr':
- $type = 'QRCODE';
-
- break;
- case 'datamatrix':
- $type = 'DATAMATRIX';
-
- break;
- case 'code39':
- $type = 'C39';
-
- break;
- case 'code93':
- $type = 'C93';
-
- break;
- case 'code128':
- $type = 'C128A';
-
- break;
- case 'none':
- return null;
- default:
- throw new InvalidArgumentException('Unknown label type!');
- }
-
- $bobj = $barcode->getBarcodeObj($type, $this->getContent($options, $target));
-
- return $bobj->getSvgCode();
- }
-
- public function getContent(LabelOptions $options, object $target): ?string
- {
- switch ($options->getBarcodeType()) {
- case 'qr':
- case 'datamatrix':
- return $this->barcodeContentGenerator->getURLContent($target);
- case 'code39':
- case 'code93':
- case 'code128':
- return $this->barcodeContentGenerator->get1DBarcodeContent($target);
- case 'none':
- return null;
- default:
- throw new InvalidArgumentException('Unknown label type!');
- }
- }
-}
diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php
new file mode 100644
index 00000000..2de7c035
--- /dev/null
+++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeRedirector.php
@@ -0,0 +1,166 @@
+.
+ */
+
+declare(strict_types=1);
+
+/**
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+namespace App\Services\LabelSystem\BarcodeScanner;
+
+use App\Entity\LabelSystem\LabelSupportedElement;
+use App\Entity\Parts\Manufacturer;
+use App\Entity\Parts\Part;
+use App\Entity\Parts\PartLot;
+use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\EntityNotFoundException;
+use InvalidArgumentException;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+
+/**
+ * @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeRedirectorTest
+ */
+final class BarcodeRedirector
+{
+ public function __construct(private readonly UrlGeneratorInterface $urlGenerator, private readonly EntityManagerInterface $em)
+ {
+ }
+
+ /**
+ * Determines the URL to which the user should be redirected, when scanning a QR code.
+ *
+ * @param BarcodeScanResultInterface $barcodeScan The result of the barcode scan
+ * @return string the URL to which should be redirected
+ *
+ * @throws EntityNotFoundException
+ */
+ public function getRedirectURL(BarcodeScanResultInterface $barcodeScan): string
+ {
+ if($barcodeScan instanceof LocalBarcodeScanResult) {
+ return $this->getURLLocalBarcode($barcodeScan);
+ }
+
+ if ($barcodeScan instanceof EIGP114BarcodeScanResult) {
+ return $this->getURLVendorBarcode($barcodeScan);
+ }
+
+ throw new InvalidArgumentException('Unknown $barcodeScan type: '.get_class($barcodeScan));
+ }
+
+ private function getURLLocalBarcode(LocalBarcodeScanResult $barcodeScan): string
+ {
+ switch ($barcodeScan->target_type) {
+ case LabelSupportedElement::PART:
+ return $this->urlGenerator->generate('app_part_show', ['id' => $barcodeScan->target_id]);
+ case LabelSupportedElement::PART_LOT:
+ //Try to determine the part to the given lot
+ $lot = $this->em->find(PartLot::class, $barcodeScan->target_id);
+ if (!$lot instanceof PartLot) {
+ throw new EntityNotFoundException();
+ }
+
+ return $this->urlGenerator->generate('app_part_show', ['id' => $lot->getPart()->getID()]);
+
+ case LabelSupportedElement::STORELOCATION:
+ return $this->urlGenerator->generate('part_list_store_location', ['id' => $barcodeScan->target_id]);
+
+ default:
+ throw new InvalidArgumentException('Unknown $type: '.$barcodeScan->target_type->name);
+ }
+ }
+
+ /**
+ * Gets the URL to a part from a scan of a Vendor Barcode
+ */
+ private function getURLVendorBarcode(EIGP114BarcodeScanResult $barcodeScan): string
+ {
+ $part = $this->getPartFromVendor($barcodeScan);
+ return $this->urlGenerator->generate('app_part_show', ['id' => $part->getID()]);
+ }
+
+ /**
+ * Gets a part from a scan of a Vendor Barcode by filtering for parts
+ * with the same Info Provider Id or, if that fails, by looking for parts with a
+ * matching manufacturer product number. Only returns the first matching part.
+ */
+ private function getPartFromVendor(EIGP114BarcodeScanResult $barcodeScan) : Part
+ {
+ // first check via the info provider ID (e.g. Vendor ID). This might fail if the part was not added via
+ // the info provider system or if the part was bought from a different vendor than the data was retrieved
+ // from.
+ if($barcodeScan->digikeyPartNumber) {
+ $qb = $this->em->getRepository(Part::class)->createQueryBuilder('part');
+ //Lower() to be case insensitive
+ $qb->where($qb->expr()->like('LOWER(part.providerReference.provider_id)', 'LOWER(:vendor_id)'));
+ $qb->setParameter('vendor_id', $barcodeScan->digikeyPartNumber);
+ $results = $qb->getQuery()->getResult();
+ if ($results) {
+ return $results[0];
+ }
+ }
+
+ if(!$barcodeScan->supplierPartNumber){
+ throw new EntityNotFoundException();
+ }
+
+ //Fallback to the manufacturer part number. This may return false positives, since it is common for
+ //multiple manufacturers to use the same part number for their version of a common product
+ //We assume the user is able to realize when this returns the wrong part
+ //If the barcode specifies the manufacturer we try to use that as well
+ $mpnQb = $this->em->getRepository(Part::class)->createQueryBuilder('part');
+ $mpnQb->where($mpnQb->expr()->like('LOWER(part.manufacturer_product_number)', 'LOWER(:mpn)'));
+ $mpnQb->setParameter('mpn', $barcodeScan->supplierPartNumber);
+
+ if($barcodeScan->mouserManufacturer){
+ $manufacturerQb = $this->em->getRepository(Manufacturer::class)->createQueryBuilder("manufacturer");
+ $manufacturerQb->where($manufacturerQb->expr()->like("LOWER(manufacturer.name)", "LOWER(:manufacturer_name)"));
+ $manufacturerQb->setParameter("manufacturer_name", $barcodeScan->mouserManufacturer);
+ $manufacturers = $manufacturerQb->getQuery()->getResult();
+
+ if($manufacturers) {
+ $mpnQb->andWhere($mpnQb->expr()->eq("part.manufacturer", ":manufacturer"));
+ $mpnQb->setParameter("manufacturer", $manufacturers);
+ }
+
+ }
+
+ $results = $mpnQb->getQuery()->getResult();
+ if($results){
+ return $results[0];
+ }
+ throw new EntityNotFoundException();
+ }
+}
diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php
new file mode 100644
index 00000000..e5930b36
--- /dev/null
+++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php
@@ -0,0 +1,243 @@
+.
+ */
+
+declare(strict_types=1);
+
+/**
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+namespace App\Services\LabelSystem\BarcodeScanner;
+
+use App\Entity\LabelSystem\LabelSupportedElement;
+use App\Entity\Parts\Part;
+use App\Entity\Parts\PartLot;
+use Doctrine\ORM\EntityManagerInterface;
+use InvalidArgumentException;
+
+/**
+ * @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeScanHelperTest
+ */
+final class BarcodeScanHelper
+{
+ private const PREFIX_TYPE_MAP = [
+ 'L' => LabelSupportedElement::PART_LOT,
+ 'P' => LabelSupportedElement::PART,
+ 'S' => LabelSupportedElement::STORELOCATION,
+ ];
+
+ public const QR_TYPE_MAP = [
+ 'lot' => LabelSupportedElement::PART_LOT,
+ 'part' => LabelSupportedElement::PART,
+ 'location' => LabelSupportedElement::STORELOCATION,
+ ];
+
+ public function __construct(private readonly EntityManagerInterface $entityManager)
+ {
+ }
+
+ /**
+ * Parse the given barcode content and return the target type and ID.
+ * If the barcode could not be parsed, an exception is thrown.
+ * Using the $type parameter, you can specify how the barcode should be parsed. If set to null, the function
+ * will try to guess the type.
+ * @param string $input
+ * @param BarcodeSourceType|null $type
+ * @return BarcodeScanResultInterface
+ */
+ public function scanBarcodeContent(string $input, ?BarcodeSourceType $type = null): BarcodeScanResultInterface
+ {
+ //Do specific parsing
+ if ($type === BarcodeSourceType::INTERNAL) {
+ return $this->parseInternalBarcode($input) ?? throw new InvalidArgumentException('Could not parse barcode');
+ }
+ if ($type === BarcodeSourceType::USER_DEFINED) {
+ return $this->parseUserDefinedBarcode($input) ?? throw new InvalidArgumentException('Could not parse barcode');
+ }
+ if ($type === BarcodeSourceType::IPN) {
+ return $this->parseIPNBarcode($input) ?? throw new InvalidArgumentException('Could not parse barcode');
+ }
+ if ($type === BarcodeSourceType::EIGP114) {
+ return $this->parseEIGP114Barcode($input);
+ }
+
+ //Null means auto and we try the different formats
+ $result = $this->parseInternalBarcode($input);
+
+ if ($result !== null) {
+ return $result;
+ }
+
+ //Try to parse as User defined barcode
+ $result = $this->parseUserDefinedBarcode($input);
+ if ($result !== null) {
+ return $result;
+ }
+
+ //If the barcode is formatted as EIGP114, we can parse it directly
+ if (EIGP114BarcodeScanResult::isFormat06Code($input)) {
+ return $this->parseEIGP114Barcode($input);
+ }
+
+ //Try to parse as IPN barcode
+ $result = $this->parseIPNBarcode($input);
+ if ($result !== null) {
+ return $result;
+ }
+
+ throw new InvalidArgumentException('Unknown barcode');
+ }
+
+ private function parseEIGP114Barcode(string $input): EIGP114BarcodeScanResult
+ {
+ return EIGP114BarcodeScanResult::parseFormat06Code($input);
+ }
+
+ private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult
+ {
+ $lot_repo = $this->entityManager->getRepository(PartLot::class);
+ //Find only the first result
+ $results = $lot_repo->findBy(['user_barcode' => $input], limit: 1);
+
+ if (count($results) === 0) {
+ return null;
+ }
+ //We found a part, so use it to create the result
+ $lot = $results[0];
+
+ return new LocalBarcodeScanResult(
+ target_type: LabelSupportedElement::PART_LOT,
+ target_id: $lot->getID(),
+ source_type: BarcodeSourceType::USER_DEFINED
+ );
+ }
+
+ private function parseIPNBarcode(string $input): ?LocalBarcodeScanResult
+ {
+ $part_repo = $this->entityManager->getRepository(Part::class);
+ //Find only the first result
+ $results = $part_repo->findBy(['ipn' => $input], limit: 1);
+
+ if (count($results) === 0) {
+ return null;
+ }
+ //We found a part, so use it to create the result
+ $part = $results[0];
+
+ return new LocalBarcodeScanResult(
+ target_type: LabelSupportedElement::PART,
+ target_id: $part->getID(),
+ source_type: BarcodeSourceType::IPN
+ );
+ }
+
+ /**
+ * This function tries to interpret the given barcode content as an internal barcode.
+ * If the barcode could not be parsed at all, null is returned. If the barcode is a valid format, but could
+ * not be found in the database, an exception is thrown.
+ * @param string $input
+ * @return LocalBarcodeScanResult|null
+ */
+ private function parseInternalBarcode(string $input): ?LocalBarcodeScanResult
+ {
+ $input = trim($input);
+ $matches = [];
+
+ //Some scanner output '-' as ß, so replace it (ß is never used, so we can replace it safely)
+ $input = str_replace('ß', '-', $input);
+
+ //Extract parts from QR code's URL
+ if (preg_match('#^https?://.*/scan/(\w+)/(\d+)/?$#', $input, $matches)) {
+ return new LocalBarcodeScanResult(
+ target_type: self::QR_TYPE_MAP[strtolower($matches[1])],
+ target_id: (int) $matches[2],
+ source_type: BarcodeSourceType::INTERNAL
+ );
+ }
+
+ //New Code39 barcode use L0001 format
+ if (preg_match('#^([A-Z])(\d{4,})$#', $input, $matches)) {
+ $prefix = $matches[1];
+ $id = (int) $matches[2];
+
+ if (!isset(self::PREFIX_TYPE_MAP[$prefix])) {
+ throw new InvalidArgumentException('Unknown prefix '.$prefix);
+ }
+
+ return new LocalBarcodeScanResult(
+ target_type: self::PREFIX_TYPE_MAP[$prefix],
+ target_id: $id,
+ source_type: BarcodeSourceType::INTERNAL
+ );
+ }
+
+ //During development the L-000001 format was used
+ if (preg_match('#^(\w)-(\d{6,})$#', $input, $matches)) {
+ $prefix = $matches[1];
+ $id = (int) $matches[2];
+
+ if (!isset(self::PREFIX_TYPE_MAP[$prefix])) {
+ throw new InvalidArgumentException('Unknown prefix '.$prefix);
+ }
+
+ return new LocalBarcodeScanResult(
+ target_type: self::PREFIX_TYPE_MAP[$prefix],
+ target_id: $id,
+ source_type: BarcodeSourceType::INTERNAL
+ );
+ }
+
+ //Legacy Part-DB location labels used $L00336 format
+ if (preg_match('#^\$L(\d{5,})$#', $input, $matches)) {
+ return new LocalBarcodeScanResult(
+ target_type: LabelSupportedElement::STORELOCATION,
+ target_id: (int) $matches[1],
+ source_type: BarcodeSourceType::INTERNAL
+ );
+ }
+
+ //Legacy Part-DB used EAN8 barcodes for part labels. Format 0000001(2) (note the optional 8th digit => checksum)
+ if (preg_match('#^(\d{7})\d?$#', $input, $matches)) {
+ return new LocalBarcodeScanResult(
+ target_type: LabelSupportedElement::PART,
+ target_id: (int) $matches[1],
+ source_type: BarcodeSourceType::INTERNAL
+ );
+ }
+
+ //This function abstain from further parsing
+ return null;
+ }
+}
diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultInterface.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultInterface.php
new file mode 100644
index 00000000..88130351
--- /dev/null
+++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultInterface.php
@@ -0,0 +1,36 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\LabelSystem\BarcodeScanner;
+
+interface BarcodeScanResultInterface
+{
+ /**
+ * Returns all data that was decoded from the barcode in a format, that can be shown in a table to the user.
+ * The return values of this function are not meant to be parsed by code again, but should just give a information
+ * to the user.
+ * The keys of the returned array are the first column of the table and the values are the second column.
+ * @return array
+ */
+ public function getDecodedForInfoMode(): array;
+}
\ No newline at end of file
diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php
new file mode 100644
index 00000000..40f707de
--- /dev/null
+++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php
@@ -0,0 +1,45 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\LabelSystem\BarcodeScanner;
+
+/**
+ * This enum represents the different types, where a barcode/QR-code can be generated from
+ */
+enum BarcodeSourceType
+{
+ /** This Barcode was generated using Part-DB internal recommended barcode generator */
+ case INTERNAL;
+ /** This barcode is containing an internal part number (IPN) */
+ case IPN;
+
+ /**
+ * This barcode is a user defined barcode defined on a part lot
+ */
+ case USER_DEFINED;
+
+ /**
+ * EIGP114 formatted barcodes like used by digikey, mouser, etc.
+ */
+ case EIGP114;
+}
\ No newline at end of file
diff --git a/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php
new file mode 100644
index 00000000..0b4f4b56
--- /dev/null
+++ b/src/Services/LabelSystem/BarcodeScanner/EIGP114BarcodeScanResult.php
@@ -0,0 +1,332 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\LabelSystem\BarcodeScanner;
+
+/**
+ * This class represents the content of a EIGP114 barcode.
+ * Based on PR 811, EIGP 114.2018 (https://www.ecianow.org/assets/docs/GIPC/EIGP-114.2018%20ECIA%20Labeling%20Specification%20for%20Product%20and%20Shipment%20Identification%20in%20the%20Electronics%20Industry%20-%202D%20Barcode.pdf),
+ * , https://forum.digikey.com/t/digikey-product-labels-decoding-digikey-barcodes/41097
+ */
+class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
+{
+
+ /**
+ * @var string|null Ship date in format YYYYMMDD
+ */
+ public readonly ?string $shipDate;
+
+ /**
+ * @var string|null Customer assigned part number – Optional based on
+ * agreements between Distributor and Supplier
+ */
+ public readonly ?string $customerPartNumber;
+
+ /**
+ * @var string|null Supplier assigned part number
+ */
+ public readonly ?string $supplierPartNumber;
+
+ /**
+ * @var int|null Quantity of product
+ */
+ public readonly ?int $quantity;
+
+ /**
+ * @var string|null Customer assigned purchase order number
+ */
+ public readonly ?string $customerPO;
+
+ /**
+ * @var string|null Line item number from PO. Required on Logistic Label when
+ * used on back of Packing Slip. See Section 4.9
+ */
+ public readonly ?string $customerPOLine;
+
+ /**
+ * 9D - YYWW (Year and Week of Manufacture). ) If no date code is used
+ * for a particular part, this field should be populated with N/T
+ * to indicate the product is Not Traceable by this data field.
+ * @var string|null
+ */
+ public readonly ?string $dateCode;
+
+ /**
+ * 10D - YYWW (Year and Week of Manufacture). ) If no date code is used
+ * for a particular part, this field should be populated with N/T
+ * to indicate the product is Not Traceable by this data field.
+ * @var string|null
+ */
+ public readonly ?string $alternativeDateCode;
+
+ /**
+ * Traceability number assigned to a batch or group of items. If
+ * no lot code is used for a particular part, this field should be
+ * populated with N/T to indicate the product is Not Traceable
+ * by this data field.
+ * @var string|null
+ */
+ public readonly ?string $lotCode;
+
+ /**
+ * Country where part was manufactured. Two-letter code from
+ * ISO 3166 country code list
+ * @var string|null
+ */
+ public readonly ?string $countryOfOrigin;
+
+ /**
+ * @var string|null Unique alphanumeric number assigned by supplier
+ * 3S - Package ID for Inner Pack when part of a mixed Logistic
+ * Carton. Always used in conjunction with a mixed logistic label
+ * with a 5S data identifier for Package ID.
+ */
+ public readonly ?string $packageId1;
+
+ /**
+ * @var string|null
+ * 4S - Package ID for Logistic Carton with like items
+ */
+ public readonly ?string $packageId2;
+
+ /**
+ * @var string|null
+ * 5S - Package ID for Logistic Carton with mixed items
+ */
+ public readonly ?string $packageId3;
+
+ /**
+ * @var string|null Unique alphanumeric number assigned by supplier.
+ */
+ public readonly ?string $packingListNumber;
+
+ /**
+ * @var string|null Ship date in format YYYYMMDD
+ */
+ public readonly ?string $serialNumber;
+
+ /**
+ * @var string|null Code for sorting and classifying LEDs. Use when applicable
+ */
+ public readonly ?string $binCode;
+
+ /**
+ * @var int|null Sequential carton count in format “#/#” or “# of #”
+ */
+ public readonly ?int $packageCount;
+
+ /**
+ * @var string|null Alphanumeric string assigned by the supplier to distinguish
+ * from one closely-related design variation to another. Use as
+ * required or when applicable
+ */
+ public readonly ?string $revisionNumber;
+
+ /**
+ * @var string|null Digikey Extension: This is not represented in the ECIA spec, but the field being used is found in the ANSI MH10.8.2-2016 spec on which the ECIA spec is based. In the ANSI spec it is called First Level (Supplier Assigned) Part Number.
+ */
+ public readonly ?string $digikeyPartNumber;
+
+ /**
+ * @var string|null Digikey Extension: This can be shared across multiple invoices and time periods and is generated as an order enters our system from any vector (web, API, phone order, etc.)
+ */
+ public readonly ?string $digikeySalesOrderNumber;
+
+ /**
+ * @var string|null Digikey extension: This is typically assigned per shipment as items are being released to be picked in the warehouse. A SO can have many Invoice numbers
+ */
+ public readonly ?string $digikeyInvoiceNumber;
+
+ /**
+ * @var string|null Digikey extension: This is for internal DigiKey purposes and defines the label type.
+ */
+ public readonly ?string $digikeyLabelType;
+
+ /**
+ * @var string|null You will also see this as the last part of a URL for a product detail page. Ex https://www.digikey.com/en/products/detail/w%C3%BCrth-elektronik/860010672008/5726907
+ */
+ public readonly ?string $digikeyPartID;
+
+ /**
+ * @var string|null Digikey Extension: For internal use of Digikey. Probably not needed
+ */
+ public readonly ?string $digikeyNA;
+
+ /**
+ * @var string|null Digikey Extension: This is a field of varying length used to keep the barcode approximately the same size between labels. It is safe to ignore.
+ */
+ public readonly ?string $digikeyPadding;
+
+ public readonly ?string $mouserPositionInOrder;
+
+ public readonly ?string $mouserManufacturer;
+
+
+
+ /**
+ *
+ * @param array $data The fields of the EIGP114 barcode, where the key is the field name and the value is the field content
+ */
+ public function __construct(public readonly array $data)
+ {
+ //IDs per EIGP 114.2018
+ $this->shipDate = $data['6D'] ?? null;
+ $this->customerPartNumber = $data['P'] ?? null;
+ $this->supplierPartNumber = $data['1P'] ?? null;
+ $this->quantity = isset($data['Q']) ? (int)$data['Q'] : null;
+ $this->customerPO = $data['K'] ?? null;
+ $this->customerPOLine = $data['4K'] ?? null;
+ $this->dateCode = $data['9D'] ?? null;
+ $this->alternativeDateCode = $data['10D'] ?? null;
+ $this->lotCode = $data['1T'] ?? null;
+ $this->countryOfOrigin = $data['4L'] ?? null;
+ $this->packageId1 = $data['3S'] ?? null;
+ $this->packageId2 = $data['4S'] ?? null;
+ $this->packageId3 = $data['5S'] ?? null;
+ $this->packingListNumber = $data['11K'] ?? null;
+ $this->serialNumber = $data['S'] ?? null;
+ $this->binCode = $data['33P'] ?? null;
+ $this->packageCount = isset($data['13Q']) ? (int)$data['13Q'] : null;
+ $this->revisionNumber = $data['2P'] ?? null;
+ //IDs used by Digikey
+ $this->digikeyPartNumber = $data['30P'] ?? null;
+ $this->digikeySalesOrderNumber = $data['1K'] ?? null;
+ $this->digikeyInvoiceNumber = $data['10K'] ?? null;
+ $this->digikeyLabelType = $data['11Z'] ?? null;
+ $this->digikeyPartID = $data['12Z'] ?? null;
+ $this->digikeyNA = $data['13Z'] ?? null;
+ $this->digikeyPadding = $data['20Z'] ?? null;
+ //IDs used by Mouser
+ $this->mouserPositionInOrder = $data['14K'] ?? null;
+ $this->mouserManufacturer = $data['1V'] ?? null;
+ }
+
+ /**
+ * Tries to guess the vendor of the barcode based on the supplied data field.
+ * This is experimental and should not be relied upon.
+ * @return string|null The guessed vendor as smallcase string (e.g. "digikey", "mouser", etc.), or null if the vendor could not be guessed
+ */
+ public function guessBarcodeVendor(): ?string
+ {
+ //If the barcode data contains the digikey extensions, we assume it is a digikey barcode
+ if (isset($this->data['13Z']) || isset($this->data['20Z']) || isset($this->data['12Z']) || isset($this->data['11Z'])) {
+ return 'digikey';
+ }
+
+ //If the barcode data contains the mouser extensions, we assume it is a mouser barcode
+ if (isset($this->data['14K']) || isset($this->data['1V'])) {
+ return 'mouser';
+ }
+
+ //According to this thread (https://github.com/inventree/InvenTree/issues/853), Newark/element14 codes contains a "3P" field
+ if (isset($this->data['3P'])) {
+ return 'element14';
+ }
+
+ return null;
+ }
+
+ /**
+ * Checks if the given input is a valid format06 formatted data.
+ * This just perform a simple check for the header, the content might be malformed still.
+ * @param string $input
+ * @return bool
+ */
+ public static function isFormat06Code(string $input): bool
+ {
+ //Code must begin with [)>06
+ if(!str_starts_with($input, "[)>\u{1E}06\u{1D}")){
+ return false;
+ }
+
+ //Digikey does not put a trailer onto the barcode, so we just check for the header
+
+ return true;
+ }
+
+ /**
+ * Parses a format06 code a returns a new instance of this class
+ * @param string $input
+ * @return self
+ */
+ public static function parseFormat06Code(string $input): self
+ {
+ //Ensure that the input is a valid format06 code
+ if (!self::isFormat06Code($input)) {
+ throw new \InvalidArgumentException("The given input is not a valid format06 code");
+ }
+
+ //Remove the trailer, if present
+ if (str_ends_with($input, "\u{1E}\u{04}")){
+ $input = substr($input, 5, -2);
+ }
+
+ //Split the input into the different fields (using the separator)
+ $parts = explode("\u{1D}", $input);
+
+ //The first field is the format identifier, which we do not need
+ array_shift($parts);
+
+ //Split the fields into key-value pairs
+ $results = [];
+
+ foreach($parts as $part) {
+ //^ 0* ([1-9]? \d* [A-Z])
+ //Start of the string Leading zeros are discarded Not a zero Any number of digits single uppercase Letter
+ // 00 1 4 K
+
+ if(!preg_match('/^0*([1-9]?\d*[A-Z])/', $part, $matches)) {
+ throw new \LogicException("Could not parse field: $part");
+ }
+ //Extract the key
+ $key = $matches[0];
+ //Extract the field value
+ $fieldValue = substr($part, strlen($matches[0]));
+
+ $results[$key] = $fieldValue;
+ }
+
+ return new self($results);
+ }
+
+ public function getDecodedForInfoMode(): array
+ {
+ $tmp = [
+ 'Barcode type' => 'EIGP114',
+ 'Guessed vendor from barcode' => $this->guessBarcodeVendor() ?? 'Unknown',
+ ];
+
+ //Iterate over all fields of this object and add them to the array if they are not null
+ foreach((array) $this as $key => $value) {
+ //Skip data key
+ if ($key === 'data') {
+ continue;
+ }
+ if($value !== null) {
+ $tmp[$key] = $value;
+ }
+ }
+
+ return $tmp;
+ }
+}
\ No newline at end of file
diff --git a/src/Services/LabelSystem/BarcodeScanner/LocalBarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/LocalBarcodeScanResult.php
new file mode 100644
index 00000000..050aff6f
--- /dev/null
+++ b/src/Services/LabelSystem/BarcodeScanner/LocalBarcodeScanResult.php
@@ -0,0 +1,49 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\LabelSystem\BarcodeScanner;
+
+use App\Entity\LabelSystem\LabelSupportedElement;
+
+/**
+ * This class represents the result of a barcode scan of a barcode that uniquely identifies a local entity,
+ * like an internally generated barcode or a barcode that was added manually to the system by a user
+ */
+class LocalBarcodeScanResult implements BarcodeScanResultInterface
+{
+ public function __construct(
+ public readonly LabelSupportedElement $target_type,
+ public readonly int $target_id,
+ public readonly BarcodeSourceType $source_type,
+ ) {
+ }
+
+ public function getDecodedForInfoMode(): array
+ {
+ return [
+ 'Barcode type' => $this->source_type->name,
+ 'Target type' => $this->target_type->name,
+ 'Target ID' => $this->target_id,
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/Services/LabelSystem/Barcodes/BarcodeContentGenerator.php b/src/Services/LabelSystem/Barcodes/BarcodeContentGenerator.php
index 588e34b4..7ceb30dd 100644
--- a/src/Services/LabelSystem/Barcodes/BarcodeContentGenerator.php
+++ b/src/Services/LabelSystem/Barcodes/BarcodeContentGenerator.php
@@ -44,29 +44,29 @@ namespace App\Services\LabelSystem\Barcodes;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
-use App\Entity\Parts\Storelocation;
+use App\Entity\Parts\StorageLocation;
use InvalidArgumentException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+/**
+ * @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeContentGeneratorTest
+ */
final class BarcodeContentGenerator
{
public const PREFIX_MAP = [
Part::class => 'P',
PartLot::class => 'L',
- Storelocation::class => 'S',
+ StorageLocation::class => 'S',
];
private const URL_MAP = [
Part::class => 'part',
PartLot::class => 'lot',
- Storelocation::class => 'location',
+ StorageLocation::class => 'location',
];
- private UrlGeneratorInterface $urlGenerator;
-
- public function __construct(UrlGeneratorInterface $urlGenerator)
+ public function __construct(private readonly UrlGeneratorInterface $urlGenerator)
{
- $this->urlGenerator = $urlGenerator;
}
/**
@@ -76,11 +76,11 @@ final class BarcodeContentGenerator
{
$type = $this->classToString(self::URL_MAP, $target);
- return $this->urlGenerator->generate('scan_qr', [
- 'type' => $type,
+ return $this->urlGenerator->generate('scan_qr', [
+ 'type' => $type,
'id' => $target->getID() ?? 0,
'_locale' => null,
-], UrlGeneratorInterface::ABSOLUTE_URL);
+ ], UrlGeneratorInterface::ABSOLUTE_URL);
}
/**
@@ -97,17 +97,17 @@ final class BarcodeContentGenerator
private function classToString(array $map, object $target): string
{
- $class = get_class($target);
+ $class = $target::class;
if (isset($map[$class])) {
return $map[$class];
}
foreach ($map as $class => $string) {
- if (is_a($target, $class)) {
+ if ($target instanceof $class) {
return $string;
}
}
- throw new InvalidArgumentException('Unknown object class '.get_class($target));
+ throw new InvalidArgumentException('Unknown object class '.$target::class);
}
}
diff --git a/src/Services/LabelSystem/Barcodes/BarcodeHelper.php b/src/Services/LabelSystem/Barcodes/BarcodeHelper.php
new file mode 100644
index 00000000..c9fe64f3
--- /dev/null
+++ b/src/Services/LabelSystem/Barcodes/BarcodeHelper.php
@@ -0,0 +1,97 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\LabelSystem\Barcodes;
+
+use App\Entity\LabelSystem\BarcodeType;
+use Com\Tecnick\Barcode\Barcode;
+
+/**
+ * This function is used to generate barcodes of various types using arbitrary (text) content.
+ * @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeHelperTest
+ */
+class BarcodeHelper
+{
+
+ /**
+ * Generates a barcode with the given content and type and returns it as SVG string.
+ * @param string $content
+ * @param BarcodeType $type
+ * @return string
+ */
+ public function barcodeAsSVG(string $content, BarcodeType $type): string
+ {
+ $barcode = new Barcode();
+
+ $type_str = match ($type) {
+ BarcodeType::NONE => throw new \InvalidArgumentException('Barcode type must not be NONE! This would make no sense...'),
+ BarcodeType::QR => 'QRCODE',
+ BarcodeType::DATAMATRIX => 'DATAMATRIX',
+ BarcodeType::CODE39 => 'C39',
+ BarcodeType::CODE93 => 'C93',
+ BarcodeType::CODE128 => 'C128A',
+ };
+
+ return $barcode->getBarcodeObj($type_str, $content)->getSvgCode();
+ }
+
+ /**
+ * Generates a barcode with the given content and type and returns it as HTML image tag.
+ * @param string $content
+ * @param BarcodeType $type
+ * @param string $width Width of the image tag
+ * @param string|null $alt_text The alt text of the image tag. If null, the content is used.
+ * @return string
+ */
+ public function barcodeAsHTML(string $content, BarcodeType $type, string $width = '100%', ?string $alt_text = null): string
+ {
+ $svg = $this->barcodeAsSVG($content, $type);
+ $base64 = $this->dataUri($svg, 'image/svg+xml');
+ $alt_text ??= $content;
+
+ return '';
+ }
+
+ /**
+ * Creates a data URI (RFC 2397).
+ * Based on the Twig implementation from HTMLExtension
+ *
+ * Length validation is not performed on purpose, validation should
+ * be done before calling this filter.
+ *
+ * @return string The generated data URI
+ */
+ private function dataUri(string $data, string $mime): string
+ {
+ $repr = 'data:';
+
+ $repr .= $mime;
+ if (str_starts_with($mime, 'text/')) {
+ $repr .= ','.rawurlencode($data);
+ } else {
+ $repr .= ';base64,'.base64_encode($data);
+ }
+
+ return $repr;
+ }
+}
\ No newline at end of file
diff --git a/src/Services/LabelSystem/Barcodes/BarcodeNormalizer.php b/src/Services/LabelSystem/Barcodes/BarcodeNormalizer.php
deleted file mode 100644
index 4e6d8cbd..00000000
--- a/src/Services/LabelSystem/Barcodes/BarcodeNormalizer.php
+++ /dev/null
@@ -1,107 +0,0 @@
-.
- */
-
-declare(strict_types=1);
-
-/**
- * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
- *
- * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published
- * by the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-namespace App\Services\LabelSystem\Barcodes;
-
-use InvalidArgumentException;
-
-final class BarcodeNormalizer
-{
- private const PREFIX_TYPE_MAP = [
- 'L' => 'lot',
- 'P' => 'part',
- 'S' => 'location',
- ];
-
- /**
- * Parses barcode content and normalizes it.
- * Returns an array in the format ['part', 1]: First entry contains element type, second the ID of the element.
- */
- public function normalizeBarcodeContent(string $input): array
- {
- $input = trim($input);
- $matches = [];
-
- //Some scanner output '-' as ß, so replace it (ß is never used, so we can replace it safely)
- $input = str_replace('ß', '-', $input);
-
- //Extract parts from QR code's URL
- if (preg_match('#^https?://.*/scan/(\w+)/(\d+)/?$#', $input, $matches)) {
- return [$matches[1], (int) $matches[2]];
- }
-
- //New Code39 barcode use L0001 format
- if (preg_match('#^([A-Z])(\d{4,})$#', $input, $matches)) {
- $prefix = $matches[1];
- $id = (int) $matches[2];
-
- if (!isset(self::PREFIX_TYPE_MAP[$prefix])) {
- throw new InvalidArgumentException('Unknown prefix '.$prefix);
- }
-
- return [self::PREFIX_TYPE_MAP[$prefix], $id];
- }
-
- //During development the L-000001 format was used
- if (preg_match('#^(\w)-(\d{6,})$#', $input, $matches)) {
- $prefix = $matches[1];
- $id = (int) $matches[2];
-
- if (!isset(self::PREFIX_TYPE_MAP[$prefix])) {
- throw new InvalidArgumentException('Unknown prefix '.$prefix);
- }
-
- return [self::PREFIX_TYPE_MAP[$prefix], $id];
- }
-
- //Legacy Part-DB location labels used $L00336 format
- if (preg_match('#^\$L(\d{5,})$#', $input, $matches)) {
- return ['location', (int) $matches[1]];
- }
-
- //Legacy Part-DB used EAN8 barcodes for part labels. Format 0000001(2) (note the optional 8th digit => checksum)
- if (preg_match('#^(\d{7})\d?$#', $input, $matches)) {
- return ['part', (int) $matches[1]];
- }
-
- throw new InvalidArgumentException('Unknown barcode format!');
- }
-}
diff --git a/src/Services/LabelSystem/Barcodes/BarcodeRedirector.php b/src/Services/LabelSystem/Barcodes/BarcodeRedirector.php
deleted file mode 100644
index 198cb43b..00000000
--- a/src/Services/LabelSystem/Barcodes/BarcodeRedirector.php
+++ /dev/null
@@ -1,92 +0,0 @@
-.
- */
-
-declare(strict_types=1);
-
-/**
- * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
- *
- * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published
- * by the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-namespace App\Services\LabelSystem\Barcodes;
-
-use App\Entity\Parts\PartLot;
-use Doctrine\ORM\EntityManagerInterface;
-use Doctrine\ORM\EntityNotFoundException;
-use InvalidArgumentException;
-use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
-
-final class BarcodeRedirector
-{
- private UrlGeneratorInterface $urlGenerator;
- private EntityManagerInterface $em;
-
- public function __construct(UrlGeneratorInterface $urlGenerator, EntityManagerInterface $entityManager)
- {
- $this->urlGenerator = $urlGenerator;
- $this->em = $entityManager;
- }
-
- /**
- * Determines the URL to which the user should be redirected, when scanning a QR code.
- *
- * @param string $type The type of the element that was scanned (e.g. 'part', 'lot', etc.)
- * @param int $id The ID of the element that was scanned
- *
- * @return string the URL to which should be redirected
- *
- * @throws EntityNotFoundException
- */
- public function getRedirectURL(string $type, int $id): string
- {
- switch ($type) {
- case 'part':
- return $this->urlGenerator->generate('app_part_show', ['id' => $id]);
- case 'lot':
- //Try to determine the part to the given lot
- $lot = $this->em->find(PartLot::class, $id);
- if (null === $lot) {
- throw new EntityNotFoundException();
- }
-
- return $this->urlGenerator->generate('app_part_show', ['id' => $lot->getPart()->getID()]);
-
- case 'location':
- return $this->urlGenerator->generate('part_list_store_location', ['id' => $id]);
-
- default:
- throw new InvalidArgumentException('Unknown $type: '.$type);
- }
- }
-}
diff --git a/src/Services/LabelSystem/DompdfFactory.php b/src/Services/LabelSystem/DompdfFactory.php
new file mode 100644
index 00000000..a2c8c3cd
--- /dev/null
+++ b/src/Services/LabelSystem/DompdfFactory.php
@@ -0,0 +1,54 @@
+.
+ */
+namespace App\Services\LabelSystem;
+
+use Dompdf\Dompdf;
+use Jbtronics\DompdfFontLoaderBundle\Services\DompdfFactoryInterface;
+use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
+
+#[AsDecorator(decorates: DompdfFactoryInterface::class)]
+class DompdfFactory implements DompdfFactoryInterface
+{
+ public function __construct(private readonly string $fontDirectory, private readonly string $tmpDirectory)
+ {
+ //Create folder if it does not exist
+ $this->createDirectoryIfNotExisting($this->fontDirectory);
+ $this->createDirectoryIfNotExisting($this->tmpDirectory);
+ }
+
+ private function createDirectoryIfNotExisting(string $path): void
+ {
+ if (!is_dir($path) && (!mkdir($concurrentDirectory = $path, 0777, true) && !is_dir($concurrentDirectory))) {
+ throw new \RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory));
+ }
+ }
+
+ public function create(): Dompdf
+ {
+ return new Dompdf([
+ 'fontDir' => $this->fontDirectory,
+ 'fontCache' => $this->fontDirectory,
+ 'tempDir' => $this->tmpDirectory,
+ ]);
+ }
+}
diff --git a/src/Services/LabelSystem/LabelBarcodeGenerator.php b/src/Services/LabelSystem/LabelBarcodeGenerator.php
new file mode 100644
index 00000000..66f74e58
--- /dev/null
+++ b/src/Services/LabelSystem/LabelBarcodeGenerator.php
@@ -0,0 +1,100 @@
+.
+ */
+
+declare(strict_types=1);
+
+/**
+ * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
+ *
+ * Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+namespace App\Services\LabelSystem;
+
+use App\Entity\Base\AbstractDBElement;
+use App\Entity\LabelSystem\BarcodeType;
+use App\Entity\LabelSystem\LabelOptions;
+use App\Services\LabelSystem\Barcodes\BarcodeContentGenerator;
+use App\Services\LabelSystem\Barcodes\BarcodeHelper;
+use InvalidArgumentException;
+
+/**
+ * @see \App\Tests\Services\LabelSystem\LabelBarcodeGeneratorTest
+ */
+final class LabelBarcodeGenerator
+{
+ public function __construct(private readonly BarcodeContentGenerator $barcodeContentGenerator, private readonly BarcodeHelper $barcodeHelper)
+ {
+ }
+
+ /**
+ * Generate the barcode for the given label as HTML image tag.
+ * @param LabelOptions $options
+ * @param AbstractDBElement $target
+ * @return string|null
+ */
+ public function generateHTMLBarcode(LabelOptions $options, AbstractDBElement $target): ?string
+ {
+ if ($options->getBarcodeType() === BarcodeType::NONE) {
+ return null;
+ }
+
+ return $this->barcodeHelper->barcodeAsHTML($this->getContent($options, $target), $options->getBarcodeType());
+ }
+
+ /**
+ * Generate the barcode for the given label as SVG string.
+ * @param LabelOptions $options
+ * @param AbstractDBElement $target
+ * @return string|null
+ */
+ public function generateSVG(LabelOptions $options, AbstractDBElement $target): ?string
+ {
+ if ($options->getBarcodeType() === BarcodeType::NONE) {
+ return null;
+ }
+
+ return $this->barcodeHelper->barcodeAsSVG($this->getContent($options, $target), $options->getBarcodeType());
+ }
+
+ public function getContent(LabelOptions $options, AbstractDBElement $target): ?string
+ {
+ $barcode = $options->getBarcodeType();
+ return match (true) {
+ $barcode->is2D() => $this->barcodeContentGenerator->getURLContent($target),
+ $barcode->is1D() => $this->barcodeContentGenerator->get1DBarcodeContent($target),
+ $barcode === BarcodeType::NONE => null,
+ default => throw new InvalidArgumentException('Unknown label type!'),
+ };
+ }
+}
diff --git a/src/Services/LabelSystem/Barcodes/BarcodeExampleElementsGenerator.php b/src/Services/LabelSystem/LabelExampleElementsGenerator.php
similarity index 71%
rename from src/Services/LabelSystem/Barcodes/BarcodeExampleElementsGenerator.php
rename to src/Services/LabelSystem/LabelExampleElementsGenerator.php
index 8a2d020d..d344c929 100644
--- a/src/Services/LabelSystem/Barcodes/BarcodeExampleElementsGenerator.php
+++ b/src/Services/LabelSystem/LabelExampleElementsGenerator.php
@@ -39,33 +39,31 @@ declare(strict_types=1);
* along with this program. If not, see .
*/
-namespace App\Services\LabelSystem\Barcodes;
+namespace App\Services\LabelSystem;
use App\Entity\Base\AbstractStructuralDBElement;
+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;
+use App\Entity\Parts\StorageLocation;
+use App\Entity\UserSystem\User;
use DateTime;
use InvalidArgumentException;
use ReflectionClass;
-final class BarcodeExampleElementsGenerator
+final class LabelExampleElementsGenerator
{
- public function getElement(string $type): object
+ public function getElement(LabelSupportedElement $type): object
{
- switch ($type) {
- case 'part':
- return $this->getExamplePart();
- case 'part_lot':
- return $this->getExamplePartLot();
- case 'storelocation':
- return $this->getStorelocation();
- default:
- throw new InvalidArgumentException('Unknown $type.');
- }
+ return match ($type) {
+ LabelSupportedElement::PART => $this->getExamplePart(),
+ LabelSupportedElement::PART_LOT => $this->getExamplePartLot(),
+ LabelSupportedElement::STORELOCATION => $this->getStorelocation(),
+ };
}
public function getExamplePart(): Part
@@ -82,7 +80,7 @@ final class BarcodeExampleElementsGenerator
$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);
@@ -99,21 +97,24 @@ final class BarcodeExampleElementsGenerator
$lot->setDescription('Example Lot');
$lot->setComment('Lot comment');
- $lot->setExpirationDate(new DateTime('+1 days'));
- $lot->setStorageLocation($this->getStructuralData(Storelocation::class));
+ $lot->setExpirationDate(new \DateTimeImmutable('+1 day'));
+ $lot->setStorageLocation($this->getStructuralData(StorageLocation::class));
$lot->setAmount(123);
+ $lot->setOwner($this->getUser());
return $lot;
}
- private function getStorelocation(): Storelocation
+ private function getStorelocation(): StorageLocation
{
- $storelocation = new Storelocation();
+ $storelocation = new StorageLocation();
$storelocation->setName('Location 1');
$storelocation->setComment('Example comment');
$storelocation->updateTimestamps();
+ $storelocation->setOwner($this->getUser());
- $parent = new Storelocation();
+
+ $parent = new StorageLocation();
$parent->setName('Parent');
$storelocation->setParent($parent);
@@ -121,17 +122,35 @@ final class BarcodeExampleElementsGenerator
return $storelocation;
}
+ private function getUser(): User
+ {
+ $user = new User();
+ $user->setName('user');
+ $user->setFirstName('John');
+ $user->setLastName('Doe');
+
+ return $user;
+ }
+
+ /**
+ * @template T of AbstractStructuralDBElement
+ * @param string $class
+ * @phpstan-param class-string $class
+ * @return AbstractStructuralDBElement
+ * @phpstan-return T
+ * @throws \ReflectionException
+ */
private function getStructuralData(string $class): AbstractStructuralDBElement
{
if (!is_a($class, AbstractStructuralDBElement::class, true)) {
throw new InvalidArgumentException('$class must be an child of AbstractStructuralDBElement');
}
- /** @var AbstractStructuralDBElement $parent */
+ /** @var T $parent */
$parent = new $class();
$parent->setName('Example');
- /** @var AbstractStructuralDBElement $child */
+ /** @var T $child */
$child = new $class();
$child->setName((new ReflectionClass($class))->getShortName());
$child->setParent($parent);
diff --git a/src/Services/LabelSystem/LabelGenerator.php b/src/Services/LabelSystem/LabelGenerator.php
index 3244875f..bfb8d27b 100644
--- a/src/Services/LabelSystem/LabelGenerator.php
+++ b/src/Services/LabelSystem/LabelGenerator.php
@@ -42,38 +42,26 @@ declare(strict_types=1);
namespace App\Services\LabelSystem;
use App\Entity\LabelSystem\LabelOptions;
-use App\Entity\Parts\Part;
-use App\Entity\Parts\PartLot;
-use App\Entity\Parts\Storelocation;
-use Dompdf\Dompdf;
use InvalidArgumentException;
+use Jbtronics\DompdfFontLoaderBundle\Services\DompdfFactoryInterface;
+/**
+ * @see \App\Tests\Services\LabelSystem\LabelGeneratorTest
+ */
final class LabelGenerator
{
- public const CLASS_SUPPORT_MAPPING = [
- 'part' => Part::class,
- 'part_lot' => PartLot::class,
- 'storelocation' => Storelocation::class,
- ];
-
public const MM_TO_POINTS_FACTOR = 2.83465;
- private LabelHTMLGenerator $labelHTMLGenerator;
-
- public function __construct(LabelHTMLGenerator $labelHTMLGenerator)
+ public function __construct(private readonly LabelHTMLGenerator $labelHTMLGenerator,
+ private readonly DompdfFactoryInterface $dompdfFactory)
{
- $this->labelHTMLGenerator = $labelHTMLGenerator;
}
/**
- * @param object|object[] $elements An element or an array of elements for which labels should be generated
+ * @param object|object[] $elements An element or an array of elements for which labels should be generated
*/
- public function generateLabel(LabelOptions $options, $elements): string
+ public function generateLabel(LabelOptions $options, object|array $elements): string
{
- if (!is_array($elements) && !is_object($elements)) {
- throw new InvalidArgumentException('$element must be an object or an array of objects!');
- }
-
if (!is_array($elements)) {
$elements = [$elements];
}
@@ -84,12 +72,12 @@ final class LabelGenerator
}
}
- $dompdf = new Dompdf();
+ $dompdf = $this->dompdfFactory->create();
$dompdf->setPaper($this->mmToPointsArray($options->getWidth(), $options->getHeight()));
$dompdf->loadHtml($this->labelHTMLGenerator->getLabelHTML($options, $elements));
$dompdf->render();
- return $dompdf->output();
+ return $dompdf->output() ?? throw new \RuntimeException('Could not generate label!');
}
/**
@@ -98,15 +86,12 @@ final class LabelGenerator
public function supports(LabelOptions $options, object $element): bool
{
$supported_type = $options->getSupportedElement();
- if (!isset(static::CLASS_SUPPORT_MAPPING[$supported_type])) {
- throw new InvalidArgumentException('Supported type name of the Label options not known!');
- }
- return is_a($element, static::CLASS_SUPPORT_MAPPING[$supported_type]);
+ return is_a($element, $supported_type->getEntityClass());
}
/**
- * Converts width and height given in mm to an size array, that can be used by DOMPDF for page size.
+ * Converts width and height given in mm to a size array, that can be used by DOMPDF for page size.
*
* @param float $width The width of the paper
* @param float $height The height of the paper
diff --git a/src/Services/LabelSystem/LabelHTMLGenerator.php b/src/Services/LabelSystem/LabelHTMLGenerator.php
index f526ac9d..42aa1e72 100644
--- a/src/Services/LabelSystem/LabelHTMLGenerator.php
+++ b/src/Services/LabelSystem/LabelHTMLGenerator.php
@@ -41,61 +41,56 @@ declare(strict_types=1);
namespace App\Services\LabelSystem;
+use App\Entity\LabelSystem\LabelProcessMode;
+use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Contracts\NamedElementInterface;
use App\Entity\LabelSystem\LabelOptions;
use App\Exceptions\TwigModeException;
use App\Services\ElementTypeNameGenerator;
use InvalidArgumentException;
-use Symfony\Component\Security\Core\Security;
use Twig\Environment;
use Twig\Error\Error;
final class LabelHTMLGenerator
{
- private Environment $twig;
- private ElementTypeNameGenerator $elementTypeNameGenerator;
- private LabelTextReplacer $replacer;
- private BarcodeGenerator $barcodeGenerator;
- private SandboxedTwigProvider $sandboxedTwigProvider;
- private string $partdb_title;
- private Security $security;
-
- public function __construct(ElementTypeNameGenerator $elementTypeNameGenerator, LabelTextReplacer $replacer, Environment $twig,
- BarcodeGenerator $barcodeGenerator, SandboxedTwigProvider $sandboxedTwigProvider, Security $security, string $partdb_title)
+ public function __construct(
+ private readonly ElementTypeNameGenerator $elementTypeNameGenerator,
+ private readonly LabelTextReplacer $replacer,
+ private readonly Environment $twig,
+ private readonly LabelBarcodeGenerator $barcodeGenerator,
+ private readonly SandboxedTwigFactory $sandboxedTwigProvider,
+ private readonly Security $security,
+ private readonly string $partdb_title)
{
- $this->twig = $twig;
- $this->elementTypeNameGenerator = $elementTypeNameGenerator;
- $this->replacer = $replacer;
- $this->barcodeGenerator = $barcodeGenerator;
- $this->sandboxedTwigProvider = $sandboxedTwigProvider;
- $this->security = $security;
- $this->partdb_title = $partdb_title;
}
public function getLabelHTML(LabelOptions $options, array $elements): string
{
- if (empty($elements)) {
+ if ($elements === []) {
throw new InvalidArgumentException('$elements must not be empty');
}
$twig_elements = [];
- if ('twig' === $options->getLinesMode()) {
- $sandboxed_twig = $this->sandboxedTwigProvider->getTwig($options);
+ if (LabelProcessMode::TWIG === $options->getProcessMode()) {
+ $sandboxed_twig = $this->sandboxedTwigProvider->createTwig($options);
$current_user = $this->security->getUser();
}
$page = 1;
foreach ($elements as $element) {
- if (isset($sandboxed_twig, $current_user) && 'twig' === $options->getLinesMode()) {
+ if (isset($sandboxed_twig, $current_user) && LabelProcessMode::TWIG === $options->getProcessMode()) {
try {
$lines = $sandboxed_twig->render(
'lines',
[
'element' => $element,
'page' => $page,
+ 'last_page' => count($elements),
'user' => $current_user,
'install_title' => $this->partdb_title,
+ 'paper_width' => $options->getWidth(),
+ 'paper_height' => $options->getHeight(),
]
);
} catch (Error $exception) {
diff --git a/src/Services/LabelSystem/LabelProfileDropdownHelper.php b/src/Services/LabelSystem/LabelProfileDropdownHelper.php
index 662922f6..773923ab 100644
--- a/src/Services/LabelSystem/LabelProfileDropdownHelper.php
+++ b/src/Services/LabelSystem/LabelProfileDropdownHelper.php
@@ -42,35 +42,43 @@ declare(strict_types=1);
namespace App\Services\LabelSystem;
use App\Entity\LabelSystem\LabelProfile;
+use App\Entity\LabelSystem\LabelSupportedElement;
use App\Repository\LabelProfileRepository;
-use App\Services\UserSystem\UserCacheKeyGenerator;
+use App\Services\Cache\ElementCacheTagGenerator;
+use App\Services\Cache\UserCacheKeyGenerator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
final class LabelProfileDropdownHelper
{
- private TagAwareCacheInterface $cache;
- private EntityManagerInterface $entityManager;
- private UserCacheKeyGenerator $keyGenerator;
-
- public function __construct(TagAwareCacheInterface $treeCache, EntityManagerInterface $entityManager, UserCacheKeyGenerator $keyGenerator)
- {
- $this->cache = $treeCache;
- $this->entityManager = $entityManager;
- $this->keyGenerator = $keyGenerator;
+ public function __construct(
+ private readonly TagAwareCacheInterface $cache,
+ private readonly EntityManagerInterface $entityManager,
+ private readonly UserCacheKeyGenerator $keyGenerator,
+ private readonly ElementCacheTagGenerator $tagGenerator,
+ ) {
}
- public function getDropdownProfiles(string $type): array
+ /**
+ * Return all label profiles for the given supported element type
+ * @param LabelSupportedElement|string $type
+ * @return array
+ */
+ public function getDropdownProfiles(LabelSupportedElement|string $type): array
{
- $secure_class_name = str_replace('\\', '_', LabelProfile::class);
- $key = 'profile_dropdown_'.$this->keyGenerator->generateKey().'_'.$secure_class_name.'_'.$type;
+ //Useful for the twig templates, where we use the string representation of the enum
+ if (is_string($type)) {
+ $type = LabelSupportedElement::from($type);
+ }
- /** @var LabelProfileRepository $repo */
+ $secure_class_name = $this->tagGenerator->getElementTypeCacheTag(LabelProfile::class);
+ $key = 'profile_dropdown_'.$this->keyGenerator->generateKey().'_'.$secure_class_name.'_'.$type->value;
+
$repo = $this->entityManager->getRepository(LabelProfile::class);
return $this->cache->get($key, function (ItemInterface $item) use ($repo, $type, $secure_class_name) {
- // Invalidate when groups, a element with the class or the user changes
+ // Invalidate when groups, an element with the class or the user changes
$item->tag(['groups', 'tree_treeview', $this->keyGenerator->generateKey(), $secure_class_name]);
return $repo->getDropdownProfiles($type);
diff --git a/src/Services/LabelSystem/LabelTextReplacer.php b/src/Services/LabelSystem/LabelTextReplacer.php
index 5b94352b..6f0a9ee8 100644
--- a/src/Services/LabelSystem/LabelTextReplacer.php
+++ b/src/Services/LabelSystem/LabelTextReplacer.php
@@ -46,14 +46,12 @@ use App\Services\LabelSystem\PlaceholderProviders\PlaceholderProviderInterface;
/**
* This service replaces the Placeholders of the user provided lines with the proper informations.
* It uses the PlaceholderProviders provided by PlaceholderProviderInterface classes.
+ * @see \App\Tests\Services\LabelSystem\LabelTextReplacerTest
*/
final class LabelTextReplacer
{
- private iterable $providers;
-
- public function __construct(iterable $providers)
+ public function __construct(private readonly iterable $providers)
{
- $this->providers = $providers;
}
/**
@@ -66,6 +64,17 @@ final class LabelTextReplacer
* @return string If the placeholder was valid, the replaced info. Otherwise the passed string.
*/
public function handlePlaceholder(string $placeholder, object $target): string
+ {
+ return $this->handlePlaceholderOrReturnNull($placeholder, $target) ?? $placeholder;
+ }
+
+ /**
+ * Similar to handlePlaceholder, but returns null if the placeholder is not known (instead of the original string)
+ * @param string $placeholder
+ * @param object $target
+ * @return string|null
+ */
+ public function handlePlaceholderOrReturnNull(string $placeholder, object $target): ?string
{
foreach ($this->providers as $provider) {
/** @var PlaceholderProviderInterface $provider */
@@ -75,25 +84,24 @@ final class LabelTextReplacer
}
}
- return $placeholder;
+ return null;
}
/**
- * Replaces all placeholders in the input lines.
+ * Replaces all placeholders in the input lines.
*
* @param string $lines The input lines that should be replaced
- * @param object $target the object that should be used as source for the informations
+ * @param object $target the object that should be used as source for the information
*
- * @return string the Lines with replaced informations
+ * @return string the Lines with replaced information
*/
public function replace(string $lines, object $target): string
{
$patterns = [
- '/(\[\[[A-Z_0-9]+\]\])/' => function ($match) use ($target) {
- return $this->handlePlaceholder($match[0], $target);
- },
+ '/(\[\[[A-Z_0-9]+\]\])/' => fn($match): string => $this->handlePlaceholder($match[0], $target),
];
- return preg_replace_callback_array($patterns, $lines);
+ return preg_replace_callback_array($patterns, $lines) ?? throw new \RuntimeException('Could not replace placeholders!');
+
}
}
diff --git a/src/Services/LabelSystem/PlaceholderProviders/AbstractDBElementProvider.php b/src/Services/LabelSystem/PlaceholderProviders/AbstractDBElementProvider.php
index f765cd0c..081b3e91 100644
--- a/src/Services/LabelSystem/PlaceholderProviders/AbstractDBElementProvider.php
+++ b/src/Services/LabelSystem/PlaceholderProviders/AbstractDBElementProvider.php
@@ -46,11 +46,8 @@ use App\Services\ElementTypeNameGenerator;
final class AbstractDBElementProvider implements PlaceholderProviderInterface
{
- private ElementTypeNameGenerator $elementTypeNameGenerator;
-
- public function __construct(ElementTypeNameGenerator $elementTypeNameGenerator)
+ public function __construct(private readonly ElementTypeNameGenerator $elementTypeNameGenerator)
{
- $this->elementTypeNameGenerator = $elementTypeNameGenerator;
}
public function replace(string $placeholder, object $label_target, array $options = []): ?string
diff --git a/src/Services/LabelSystem/PlaceholderProviders/BarcodeProvider.php b/src/Services/LabelSystem/PlaceholderProviders/BarcodeProvider.php
index 9fbcd293..400fef35 100644
--- a/src/Services/LabelSystem/PlaceholderProviders/BarcodeProvider.php
+++ b/src/Services/LabelSystem/PlaceholderProviders/BarcodeProvider.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Services\LabelSystem\PlaceholderProviders;
+use App\Entity\LabelSystem\BarcodeType;
use App\Entity\LabelSystem\LabelOptions;
-use App\Entity\LabelSystem\LabelProfile;
-use App\Services\LabelSystem\BarcodeGenerator;
+use App\Entity\Parts\Part;
+use App\Entity\Parts\PartLot;
+use App\Services\LabelSystem\Barcodes\BarcodeHelper;
+use App\Services\LabelSystem\LabelBarcodeGenerator;
use App\Services\LabelSystem\Barcodes\BarcodeContentGenerator;
+use Com\Tecnick\Barcode\Exception;
final class BarcodeProvider implements PlaceholderProviderInterface
{
- private BarcodeGenerator $barcodeGenerator;
- private BarcodeContentGenerator $barcodeContentGenerator;
-
- public function __construct(BarcodeGenerator $barcodeGenerator, BarcodeContentGenerator $barcodeContentGenerator)
+ public function __construct(private readonly LabelBarcodeGenerator $barcodeGenerator,
+ private readonly BarcodeContentGenerator $barcodeContentGenerator,
+ private readonly BarcodeHelper $barcodeHelper)
{
- $this->barcodeGenerator = $barcodeGenerator;
- $this->barcodeContentGenerator = $barcodeContentGenerator;
}
public function replace(string $placeholder, object $label_target, array $options = []): ?string
@@ -41,7 +44,7 @@ final class BarcodeProvider implements PlaceholderProviderInterface
if ('[[1D_CONTENT]]' === $placeholder) {
try {
return $this->barcodeContentGenerator->get1DBarcodeContent($label_target);
- } catch (\InvalidArgumentException $e) {
+ } catch (\InvalidArgumentException) {
return 'ERROR!';
}
}
@@ -49,29 +52,72 @@ final class BarcodeProvider implements PlaceholderProviderInterface
if ('[[2D_CONTENT]]' === $placeholder) {
try {
return $this->barcodeContentGenerator->getURLContent($label_target);
- } catch (\InvalidArgumentException $e) {
+ } catch (\InvalidArgumentException) {
return 'ERROR!';
}
}
if ('[[BARCODE_QR]]' === $placeholder) {
$label_options = new LabelOptions();
- $label_options->setBarcodeType('qr');
+ $label_options->setBarcodeType(BarcodeType::QR);
+ return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
+ }
+
+ if ('[[BARCODE_DATAMATRIX]]' === $placeholder) {
+ $label_options = new LabelOptions();
+ $label_options->setBarcodeType(BarcodeType::DATAMATRIX);
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
}
if ('[[BARCODE_C39]]' === $placeholder) {
$label_options = new LabelOptions();
- $label_options->setBarcodeType('code39');
+ $label_options->setBarcodeType(BarcodeType::CODE39);
+ return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
+ }
+
+ if ('[[BARCODE_C93]]' === $placeholder) {
+ $label_options = new LabelOptions();
+ $label_options->setBarcodeType(BarcodeType::CODE93);
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
}
if ('[[BARCODE_C128]]' === $placeholder) {
$label_options = new LabelOptions();
- $label_options->setBarcodeType('code128');
+ $label_options->setBarcodeType(BarcodeType::CODE128);
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
}
+ if (($label_target instanceof Part || $label_target instanceof PartLot)
+ && str_starts_with($placeholder, '[[IPN_BARCODE_')) {
+ if ($label_target instanceof PartLot) {
+ $label_target = $label_target->getPart();
+ }
+
+ if ($label_target === null || $label_target->getIPN() === null || $label_target->getIPN() === '') {
+ //Replace with empty result, if no IPN is set
+ return '';
+ }
+
+ try {
+ //Add placeholders for the IPN barcode
+ if ('[[IPN_BARCODE_C39]]' === $placeholder) {
+ return $this->barcodeHelper->barcodeAsHTML($label_target->getIPN(), BarcodeType::CODE39);
+ }
+ if ('[[IPN_BARCODE_C128]]' === $placeholder) {
+ return $this->barcodeHelper->barcodeAsHTML($label_target->getIPN(), BarcodeType::CODE128);
+ }
+ if ('[[IPN_BARCODE_QR]]' === $placeholder) {
+ return $this->barcodeHelper->barcodeAsHTML($label_target->getIPN(), BarcodeType::QR);
+ }
+ } catch (Exception $e) {
+ //If an error occurs, output it
+ return 'IPN Barcode ERROR!: '.$e->getMessage();
+ }
+ }
+
+
+
+
return null;
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/LabelSystem/PlaceholderProviders/GlobalProviders.php b/src/Services/LabelSystem/PlaceholderProviders/GlobalProviders.php
index 1dd7188a..ddd4dbf1 100644
--- a/src/Services/LabelSystem/PlaceholderProviders/GlobalProviders.php
+++ b/src/Services/LabelSystem/PlaceholderProviders/GlobalProviders.php
@@ -41,28 +41,21 @@ declare(strict_types=1);
namespace App\Services\LabelSystem\PlaceholderProviders;
+use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\UserSystem\User;
use DateTime;
use IntlDateFormatter;
use Locale;
-use Symfony\Component\Routing\Generator\UrlGenerator;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
-use Symfony\Component\Security\Core\Security;
/**
* Provides Placeholders for infos about global infos like Installation name or datetimes.
+ * @see \App\Tests\Services\LabelSystem\PlaceholderProviders\GlobalProvidersTest
*/
final class GlobalProviders implements PlaceholderProviderInterface
{
- private string $partdb_title;
- private Security $security;
- private UrlGeneratorInterface $url_generator;
-
- public function __construct(string $partdb_title, Security $security, UrlGeneratorInterface $url_generator)
+ public function __construct(private readonly string $partdb_title, private readonly Security $security, private readonly UrlGeneratorInterface $url_generator)
{
- $this->partdb_title = $partdb_title;
- $this->security = $security;
- $this->url_generator = $url_generator;
}
public function replace(string $placeholder, object $label_target, array $options = []): ?string
@@ -88,7 +81,7 @@ final class GlobalProviders implements PlaceholderProviderInterface
return 'anonymous';
}
- $now = new DateTime();
+ $now = new \DateTimeImmutable();
if ('[[DATETIME]]' === $placeholder) {
$formatter = IntlDateFormatter::create(
diff --git a/src/Services/LabelSystem/PlaceholderProviders/NamedElementProvider.php b/src/Services/LabelSystem/PlaceholderProviders/NamedElementProvider.php
index fc5fedfe..d8d38120 100644
--- a/src/Services/LabelSystem/PlaceholderProviders/NamedElementProvider.php
+++ b/src/Services/LabelSystem/PlaceholderProviders/NamedElementProvider.php
@@ -43,6 +43,9 @@ namespace App\Services\LabelSystem\PlaceholderProviders;
use App\Entity\Contracts\NamedElementInterface;
+/**
+ * @see \App\Tests\Services\LabelSystem\PlaceholderProviders\NamedElementProviderTest
+ */
final class NamedElementProvider implements PlaceholderProviderInterface
{
public function replace(string $placeholder, object $label_target, array $options = []): ?string
diff --git a/src/Services/LabelSystem/PlaceholderProviders/PartLotProvider.php b/src/Services/LabelSystem/PlaceholderProviders/PartLotProvider.php
index 3f204f32..946b4892 100644
--- a/src/Services/LabelSystem/PlaceholderProviders/PartLotProvider.php
+++ b/src/Services/LabelSystem/PlaceholderProviders/PartLotProvider.php
@@ -41,21 +41,21 @@ declare(strict_types=1);
namespace App\Services\LabelSystem\PlaceholderProviders;
+use App\Entity\Parts\StorageLocation;
+use App\Entity\UserSystem\User;
use App\Entity\Parts\PartLot;
use App\Services\Formatters\AmountFormatter;
use App\Services\LabelSystem\LabelTextReplacer;
use IntlDateFormatter;
use Locale;
+/**
+ * @see \App\Tests\Services\LabelSystem\PlaceholderProviders\PartLotProviderTest
+ */
final class PartLotProvider implements PlaceholderProviderInterface
{
- private LabelTextReplacer $labelTextReplacer;
- private AmountFormatter $amountFormatter;
-
- public function __construct(LabelTextReplacer $labelTextReplacer, AmountFormatter $amountFormatter)
+ public function __construct(private readonly LabelTextReplacer $labelTextReplacer, private readonly AmountFormatter $amountFormatter)
{
- $this->labelTextReplacer = $labelTextReplacer;
- $this->amountFormatter = $amountFormatter;
}
public function replace(string $placeholder, object $label_target, array $options = []): ?string
@@ -74,7 +74,7 @@ final class PartLotProvider implements PlaceholderProviderInterface
}
if ('[[EXPIRATION_DATE]]' === $placeholder) {
- if (null === $label_target->getExpirationDate()) {
+ if (!$label_target->getExpirationDate() instanceof \DateTimeInterface) {
return '';
}
$formatter = IntlDateFormatter::create(
@@ -95,11 +95,19 @@ final class PartLotProvider implements PlaceholderProviderInterface
}
if ('[[LOCATION]]' === $placeholder) {
- return $label_target->getStorageLocation() ? $label_target->getStorageLocation()->getName() : '';
+ return $label_target->getStorageLocation() instanceof StorageLocation ? $label_target->getStorageLocation()->getName() : '';
}
if ('[[LOCATION_FULL]]' === $placeholder) {
- return $label_target->getStorageLocation() ? $label_target->getStorageLocation()->getFullPath() : '';
+ return $label_target->getStorageLocation() instanceof StorageLocation ? $label_target->getStorageLocation()->getFullPath() : '';
+ }
+
+ if ('[[OWNER]]' === $placeholder) {
+ return $label_target->getOwner() instanceof User ? $label_target->getOwner()->getFullName() : '';
+ }
+
+ if ('[[OWNER_USERNAME]]' === $placeholder) {
+ return $label_target->getOwner() instanceof User ? $label_target->getOwner()->getUsername() : '';
}
return $this->labelTextReplacer->handlePlaceholder($placeholder, $label_target->getPart());
diff --git a/src/Services/LabelSystem/PlaceholderProviders/PartProvider.php b/src/Services/LabelSystem/PlaceholderProviders/PartProvider.php
index 48e547f6..0df4d3d7 100644
--- a/src/Services/LabelSystem/PlaceholderProviders/PartProvider.php
+++ b/src/Services/LabelSystem/PlaceholderProviders/PartProvider.php
@@ -41,20 +41,21 @@ declare(strict_types=1);
namespace App\Services\LabelSystem\PlaceholderProviders;
+use App\Entity\Parts\Category;
+use App\Entity\Parts\Manufacturer;
+use App\Entity\Parts\Footprint;
use App\Entity\Parts\Part;
use App\Services\Formatters\SIFormatter;
use Parsedown;
use Symfony\Contracts\Translation\TranslatorInterface;
+/**
+ * @see \App\Tests\Services\LabelSystem\PlaceholderProviders\PartProviderTest
+ */
final class PartProvider implements PlaceholderProviderInterface
{
- private SIFormatter $siFormatter;
- private TranslatorInterface $translator;
-
- public function __construct(SIFormatter $SIFormatter, TranslatorInterface $translator)
+ public function __construct(private readonly SIFormatter $siFormatter, private readonly TranslatorInterface $translator)
{
- $this->siFormatter = $SIFormatter;
- $this->translator = $translator;
}
public function replace(string $placeholder, object $part, array $options = []): ?string
@@ -64,27 +65,27 @@ final class PartProvider implements PlaceholderProviderInterface
}
if ('[[CATEGORY]]' === $placeholder) {
- return $part->getCategory() ? $part->getCategory()->getName() : '';
+ return $part->getCategory() instanceof Category ? $part->getCategory()->getName() : '';
}
if ('[[CATEGORY_FULL]]' === $placeholder) {
- return $part->getCategory() ? $part->getCategory()->getFullPath() : '';
+ return $part->getCategory() instanceof Category ? $part->getCategory()->getFullPath() : '';
}
if ('[[MANUFACTURER]]' === $placeholder) {
- return $part->getManufacturer() ? $part->getManufacturer()->getName() : '';
+ return $part->getManufacturer() instanceof Manufacturer ? $part->getManufacturer()->getName() : '';
}
if ('[[MANUFACTURER_FULL]]' === $placeholder) {
- return $part->getManufacturer() ? $part->getManufacturer()->getFullPath() : '';
+ return $part->getManufacturer() instanceof Manufacturer ? $part->getManufacturer()->getFullPath() : '';
}
if ('[[FOOTPRINT]]' === $placeholder) {
- return $part->getFootprint() ? $part->getFootprint()->getName() : '';
+ return $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getName() : '';
}
if ('[[FOOTPRINT_FULL]]' === $placeholder) {
- return $part->getFootprint() ? $part->getFootprint()->getFullPath() : '';
+ return $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getFullPath() : '';
}
if ('[[MASS]]' === $placeholder) {
@@ -95,16 +96,20 @@ final class PartProvider implements PlaceholderProviderInterface
return $part->getManufacturerProductNumber();
}
+ if ('[[IPN]]' === $placeholder) {
+ return $part->getIpn() ?? '';
+ }
+
if ('[[TAGS]]' === $placeholder) {
return $part->getTags();
}
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();
@@ -114,7 +119,7 @@ final class PartProvider implements PlaceholderProviderInterface
}
if ('[[DESCRIPTION_T]]' === $placeholder) {
- return strip_tags($parsedown->line($part->getDescription()));
+ return strip_tags((string) $parsedown->line($part->getDescription()));
}
if ('[[COMMENT]]' === $placeholder) {
@@ -122,7 +127,7 @@ final class PartProvider implements PlaceholderProviderInterface
}
if ('[[COMMENT_T]]' === $placeholder) {
- return strip_tags($parsedown->line($part->getComment()));
+ return strip_tags((string) $parsedown->line($part->getComment()));
}
return null;
diff --git a/src/Services/LabelSystem/PlaceholderProviders/StorelocationProvider.php b/src/Services/LabelSystem/PlaceholderProviders/StorelocationProvider.php
new file mode 100644
index 00000000..4b4d8dcd
--- /dev/null
+++ b/src/Services/LabelSystem/PlaceholderProviders/StorelocationProvider.php
@@ -0,0 +1,44 @@
+.
+ */
+namespace App\Services\LabelSystem\PlaceholderProviders;
+
+use App\Entity\UserSystem\User;
+use App\Entity\Parts\StorageLocation;
+
+class StorelocationProvider implements PlaceholderProviderInterface
+{
+ public function replace(string $placeholder, object $label_target, array $options = []): ?string
+ {
+ if ($label_target instanceof StorageLocation) {
+ if ('[[OWNER]]' === $placeholder) {
+ return $label_target->getOwner() instanceof User ? $label_target->getOwner()->getFullName() : '';
+ }
+
+ if ('[[OWNER_USERNAME]]' === $placeholder) {
+ return $label_target->getOwner() instanceof User ? $label_target->getOwner()->getUsername() : '';
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/Services/LabelSystem/PlaceholderProviders/StructuralDBElementProvider.php b/src/Services/LabelSystem/PlaceholderProviders/StructuralDBElementProvider.php
index f4aebd8a..f37f5901 100644
--- a/src/Services/LabelSystem/PlaceholderProviders/StructuralDBElementProvider.php
+++ b/src/Services/LabelSystem/PlaceholderProviders/StructuralDBElementProvider.php
@@ -52,16 +52,16 @@ final class StructuralDBElementProvider implements PlaceholderProviderInterface
return $label_target->getComment();
}
if ('[[COMMENT_T]]' === $placeholder) {
- return strip_tags($label_target->getComment());
+ return strip_tags((string) $label_target->getComment());
}
if ('[[FULL_PATH]]' === $placeholder) {
return $label_target->getFullPath();
}
if ('[[PARENT]]' === $placeholder) {
- return $label_target->getParent() ? $label_target->getParent()->getName() : '';
+ return $label_target->getParent() instanceof AbstractStructuralDBElement ? $label_target->getParent()->getName() : '';
}
if ('[[PARENT_FULL_PATH]]' === $placeholder) {
- return $label_target->getParent() ? $label_target->getParent()->getFullPath() : '';
+ return $label_target->getParent() instanceof AbstractStructuralDBElement ? $label_target->getParent()->getFullPath() : '';
}
}
diff --git a/src/Services/LabelSystem/PlaceholderProviders/TimestampableElementProvider.php b/src/Services/LabelSystem/PlaceholderProviders/TimestampableElementProvider.php
index ef5967b2..b316abf2 100644
--- a/src/Services/LabelSystem/PlaceholderProviders/TimestampableElementProvider.php
+++ b/src/Services/LabelSystem/PlaceholderProviders/TimestampableElementProvider.php
@@ -42,10 +42,12 @@ declare(strict_types=1);
namespace App\Services\LabelSystem\PlaceholderProviders;
use App\Entity\Contracts\TimeStampableInterface;
-use DateTime;
use IntlDateFormatter;
use Locale;
+/**
+ * @see \App\Tests\Services\LabelSystem\PlaceholderProviders\TimestampableElementProviderTest
+ */
final class TimestampableElementProvider implements PlaceholderProviderInterface
{
public function replace(string $placeholder, object $label_target, array $options = []): ?string
@@ -54,11 +56,11 @@ final class TimestampableElementProvider implements PlaceholderProviderInterface
$formatter = new IntlDateFormatter(Locale::getDefault(), IntlDateFormatter::SHORT, IntlDateFormatter::SHORT);
if ('[[LAST_MODIFIED]]' === $placeholder) {
- return $formatter->format($label_target->getLastModified() ?? new DateTime());
+ return $formatter->format($label_target->getLastModified() ?? new \DateTimeImmutable());
}
if ('[[CREATION_DATE]]' === $placeholder) {
- return $formatter->format($label_target->getAddedDate() ?? new DateTime());
+ return $formatter->format($label_target->getAddedDate() ?? new \DateTimeImmutable());
}
}
diff --git a/src/Services/LabelSystem/SandboxedTwigProvider.php b/src/Services/LabelSystem/SandboxedTwigFactory.php
similarity index 59%
rename from src/Services/LabelSystem/SandboxedTwigProvider.php
rename to src/Services/LabelSystem/SandboxedTwigFactory.php
index 66488fca..d6ea6968 100644
--- a/src/Services/LabelSystem/SandboxedTwigProvider.php
+++ b/src/Services/LabelSystem/SandboxedTwigFactory.php
@@ -49,81 +49,125 @@ use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Contracts\NamedElementInterface;
use App\Entity\Contracts\TimeStampableInterface;
use App\Entity\LabelSystem\LabelOptions;
+use App\Entity\LabelSystem\LabelProcessMode;
use App\Entity\Parameters\AbstractParameter;
+use App\Entity\Parts\InfoProviderReference;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
+use App\Entity\Parts\PartAssociation;
use App\Entity\Parts\PartLot;
-use App\Entity\Parts\Storelocation;
+use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\PriceInformations\Pricedetail;
use App\Entity\UserSystem\User;
+use App\Twig\BarcodeExtension;
+use App\Twig\EntityExtension;
use App\Twig\FormatExtension;
use App\Twig\Sandbox\InheritanceSecurityPolicy;
+use App\Twig\Sandbox\SandboxedLabelExtension;
+use App\Twig\TwigCoreExtension;
use InvalidArgumentException;
use Twig\Environment;
use Twig\Extension\SandboxExtension;
+use Twig\Extra\Html\HtmlExtension;
use Twig\Extra\Intl\IntlExtension;
+use Twig\Extra\Markdown\MarkdownExtension;
+use Twig\Extra\String\StringExtension;
use Twig\Loader\ArrayLoader;
use Twig\Sandbox\SecurityPolicyInterface;
-final class SandboxedTwigProvider
+/**
+ * This service creates a sandboxed twig environment for the label system.
+ * @see \App\Tests\Services\LabelSystem\SandboxedTwigFactoryTest
+ */
+final class SandboxedTwigFactory
{
private const ALLOWED_TAGS = ['apply', 'autoescape', 'do', 'for', 'if', 'set', 'verbatim', 'with'];
private const ALLOWED_FILTERS = ['abs', 'batch', 'capitalize', 'column', 'country_name',
- 'currency_name', 'currency_symbol', 'date', 'date_modify', 'default', 'escape', 'filter', 'first', 'format',
- 'format_currency', 'format_date', 'format_datetime', 'format_number', 'format_time', 'join', 'keys',
- 'language_name', 'last', 'length', 'locale_name', 'lower', 'map', 'merge', 'nl2br', 'raw', 'number_format',
- 'reduce', 'replace', 'reverse', 'slice', 'sort', 'spaceless', 'split', 'striptags', 'timezone_name', 'title',
- 'trim', 'upper', 'url_encode',
- //Part-DB specific filters:
- 'moneyFormat', 'siFormat', 'amountFormat', ];
+ 'currency_name', 'currency_symbol', 'date', 'date_modify', 'data_uri', 'default', 'escape', 'filter', 'first', 'format',
+ 'format_currency', 'format_date', 'format_datetime', 'format_number', 'format_time', 'html_to_markdown', 'join', 'keys',
+ 'language_name', 'last', 'length', 'locale_name', 'lower', 'map', 'markdown_to_html', 'merge', 'nl2br', 'raw', 'number_format',
+ 'reduce', 'replace', 'reverse', 'round', 'slice', 'slug', 'sort', 'spaceless', 'split', 'striptags', 'timezone_name', 'title',
+ 'trim', 'u', 'upper', 'url_encode',
- private const ALLOWED_FUNCTIONS = ['date', 'html_classes', 'max', 'min', 'random', 'range'];
+ //Part-DB specific filters:
+
+ //FormatExtension:
+ 'format_money', 'format_si', 'format_amount', 'format_bytes',
+
+ //SandboxedLabelExtension
+ 'placeholders',
+ ];
+
+ private const ALLOWED_FUNCTIONS = ['country_names', 'country_timezones', 'currency_names', 'cycle',
+ 'date', 'html_classes', 'language_names', 'locale_names', 'max', 'min', 'random', 'range', 'script_names',
+ 'template_from_string', 'timezone_names',
+
+ //Part-DB specific extensions:
+ //EntityExtension:
+ 'entity_type', 'entity_url',
+ //BarcodeExtension:
+ 'barcode_svg',
+ //SandboxedLabelExtension
+ 'placeholder',
+ ];
private const ALLOWED_METHODS = [
NamedElementInterface::class => ['getName'],
AbstractDBElement::class => ['getID', '__toString'],
TimeStampableInterface::class => ['getLastModified', 'getAddedDate'],
AbstractStructuralDBElement::class => ['isChildOf', 'isRoot', 'getParent', 'getComment', 'getLevel',
- 'getFullPath', 'getPathArray', 'getChildren', 'isNotSelectable', ],
- AbstractCompany::class => ['getAddress', 'getPhoneNumber', 'getFaxNumber', 'getEmailAddress', 'getWebsite'],
+ 'getFullPath', 'getPathArray', 'getSubelements', 'getChildren', 'isNotSelectable', ],
+ AbstractCompany::class => ['getAddress', 'getPhoneNumber', 'getFaxNumber', 'getEmailAddress', 'getWebsite', 'getAutoProductUrl'],
AttachmentContainingDBElement::class => ['getAttachments', 'getMasterPictureAttachment'],
- Attachment::class => ['isPicture', 'is3DModel', 'isExternal', 'isSecure', 'isBuiltIn', 'getExtension',
- 'getElement', 'getURL', 'getFilename', 'getAttachmentType', 'getShowInTable', ],
+ Attachment::class => ['isPicture', 'is3DModel', 'hasExternal', 'hasInternal', 'isSecure', 'isBuiltIn', 'getExtension',
+ 'getElement', 'getExternalPath', 'getHost', 'getFilename', 'getAttachmentType', 'getShowInTable'],
AbstractParameter::class => ['getFormattedValue', 'getGroup', 'getSymbol', 'getValueMin', 'getValueMax',
'getValueTypical', 'getUnit', 'getValueText', ],
MeasurementUnit::class => ['getUnit', 'isInteger', 'useSIPrefix'],
PartLot::class => ['isExpired', 'getDescription', 'getComment', 'getExpirationDate', 'getStorageLocation',
- 'getPart', 'isInstockUnknown', 'getAmount', 'getNeedsRefill', ],
- Storelocation::class => ['isFull', 'isOnlySinglePart', 'isLimitToExistingParts', 'getStorageType'],
+ 'getPart', 'isInstockUnknown', 'getAmount', 'getNeedsRefill', 'getVendorBarcode'],
+ StorageLocation::class => ['isFull', 'isOnlySinglePart', 'isLimitToExistingParts', 'getStorageType'],
Supplier::class => ['getShippingCosts', 'getDefaultCurrency'],
- Part::class => ['isNeedsReview', 'getTags', 'getMass', 'getDescription', 'isFavorite', 'getCategory',
- 'getFootprint', 'getPartLots', 'getPartUnit', 'useFloatAmount', 'getMinAmount', 'getAmountSum',
+ Part::class => ['isNeedsReview', 'getTags', 'getMass', 'getIpn', 'getProviderReference',
+ 'getDescription', 'getComment', 'isFavorite', 'getCategory', 'getFootprint',
+ 'getPartLots', 'getPartUnit', 'useFloatAmount', 'getMinAmount', 'getAmountSum', 'isNotEnoughInstock', 'isAmountUnknown', 'getExpiredAmountSum',
'getManufacturerProductUrl', 'getCustomProductURL', 'getManufacturingStatus', 'getManufacturer',
- 'getManufacturerProductNumber', 'getOrderdetails', 'isObsolete', ],
+ 'getManufacturerProductNumber', 'getOrderdetails', 'isObsolete',
+ 'getParameters', 'getGroupedParameters',
+ 'isProjectBuildPart', 'getBuiltProject',
+ 'getAssociatedPartsAsOwner', 'getAssociatedPartsAsOther', 'getAssociatedPartsAll',
+ 'getEdaInfo'
+ ],
Currency::class => ['getIsoCode', 'getInverseExchangeRate', 'getExchangeRate'],
Orderdetail::class => ['getPart', 'getSupplier', 'getSupplierPartNr', 'getObsolete',
- 'getPricedetails', 'findPriceForQty', ],
+ 'getPricedetails', 'findPriceForQty', 'isObsolete', 'getSupplierProductUrl'],
Pricedetail::class => ['getOrderdetail', 'getPrice', 'getPricePerUnit', 'getPriceRelatedQuantity',
- 'getMinDiscountQuantity', 'getCurrency', ],
+ 'getMinDiscountQuantity', 'getCurrency', 'getCurrencyISOCode'],
+ InfoProviderReference:: class => ['getProviderKey', 'getProviderId', 'getProviderUrl', 'getLastUpdated', 'isProviderCreated'],
+ PartAssociation::class => ['getType', 'getComment', 'getOwner', 'getOther', 'getOtherType'],
+
//Only allow very little information about users...
User::class => ['isAnonymousUser', 'getUsername', 'getFullName', 'getFirstName', 'getLastName',
'getDepartment', 'getEmail', ],
];
private const ALLOWED_PROPERTIES = [];
- private FormatExtension $appExtension;
-
- public function __construct(FormatExtension $appExtension)
+ public function __construct(
+ private readonly FormatExtension $formatExtension,
+ private readonly BarcodeExtension $barcodeExtension,
+ private readonly EntityExtension $entityExtension,
+ private readonly TwigCoreExtension $twigCoreExtension,
+ private readonly SandboxedLabelExtension $sandboxedLabelExtension,
+ )
{
- $this->appExtension = $appExtension;
}
- public function getTwig(LabelOptions $options): Environment
+ public function createTwig(LabelOptions $options): Environment
{
- if ('twig' !== $options->getLinesMode()) {
+ if (LabelProcessMode::TWIG !== $options->getProcessMode()) {
throw new InvalidArgumentException('The LabelOptions must explicitly allow twig via lines_mode = "twig"!');
}
@@ -138,9 +182,16 @@ final class SandboxedTwigProvider
//Add IntlExtension
$twig->addExtension(new IntlExtension());
+ $twig->addExtension(new MarkdownExtension());
+ $twig->addExtension(new StringExtension());
+ $twig->addExtension(new HtmlExtension());
//Add Part-DB specific extension
- $twig->addExtension($this->appExtension);
+ $twig->addExtension($this->formatExtension);
+ $twig->addExtension($this->barcodeExtension);
+ $twig->addExtension($this->entityExtension);
+ $twig->addExtension($this->twigCoreExtension);
+ $twig->addExtension($this->sandboxedLabelExtension);
return $twig;
}
diff --git a/src/Services/LogSystem/EventCommentHelper.php b/src/Services/LogSystem/EventCommentHelper.php
index 4afcf04d..45e95b2c 100644
--- a/src/Services/LogSystem/EventCommentHelper.php
+++ b/src/Services/LogSystem/EventCommentHelper.php
@@ -41,15 +41,17 @@ declare(strict_types=1);
namespace App\Services\LogSystem;
+/**
+ * @see \App\Tests\Services\LogSystem\EventCommentHelperTest
+ */
class EventCommentHelper
{
protected const MAX_MESSAGE_LENGTH = 255;
- protected ?string $message;
+ protected ?string $message = null;
public function __construct()
{
- $this->message = null;
}
/**
@@ -60,11 +62,7 @@ class EventCommentHelper
public function setMessage(?string $message): void
{
//Restrict the length of the string
- if ($message) {
- $this->message = mb_strimwidth($message, 0, self::MAX_MESSAGE_LENGTH, '...');
- } else {
- $this->message = null;
- }
+ $this->message = $message ? mb_strimwidth($message, 0, self::MAX_MESSAGE_LENGTH, '...') : null;
}
/**
diff --git a/src/Services/LogSystem/EventCommentNeededHelper.php b/src/Services/LogSystem/EventCommentNeededHelper.php
new file mode 100644
index 00000000..8440f199
--- /dev/null
+++ b/src/Services/LogSystem/EventCommentNeededHelper.php
@@ -0,0 +1,58 @@
+.
+ */
+namespace App\Services\LogSystem;
+
+/**
+ * This service is used to check if a log change comment is needed for a given operation type.
+ * It is configured using the "enforce_change_comments_for" config parameter.
+ * @see \App\Tests\Services\LogSystem\EventCommentNeededHelperTest
+ */
+class EventCommentNeededHelper
+{
+ final public const VALID_OPERATION_TYPES = [
+ 'part_edit',
+ 'part_create',
+ 'part_delete',
+ 'part_stock_operation',
+ 'datastructure_edit',
+ 'datastructure_create',
+ 'datastructure_delete',
+ ];
+
+ public function __construct(protected array $enforce_change_comments_for)
+ {
+ }
+
+ /**
+ * Checks if a log change comment is needed for the given operation type
+ */
+ public function isCommentNeeded(string $comment_type): bool
+ {
+ //Check if the comment type is valid
+ if (! in_array($comment_type, self::VALID_OPERATION_TYPES, true)) {
+ throw new \InvalidArgumentException('The comment type "'.$comment_type.'" is not valid!');
+ }
+
+ return in_array($comment_type, $this->enforce_change_comments_for, true);
+ }
+}
diff --git a/src/Services/LogSystem/EventLogger.php b/src/Services/LogSystem/EventLogger.php
index 80ee067e..11147de6 100644
--- a/src/Services/LogSystem/EventLogger.php
+++ b/src/Services/LogSystem/EventLogger.php
@@ -22,26 +22,24 @@ declare(strict_types=1);
namespace App\Services\LogSystem;
+use App\Entity\LogSystem\LogLevel;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\Security\Core\User\UserInterface;
use App\Entity\LogSystem\AbstractLogEntry;
use App\Entity\UserSystem\User;
+use App\Services\Misc\ConsoleInfoHelper;
use Doctrine\ORM\EntityManagerInterface;
-use Symfony\Component\Security\Core\Security;
+/**
+ * @see \App\Tests\Services\LogSystem\EventLoggerTest
+ */
class EventLogger
{
- protected int $minimum_log_level;
- protected array $blacklist;
- protected array $whitelist;
- protected EntityManagerInterface $em;
- protected Security $security;
+ protected LogLevel $minimum_log_level;
- public function __construct(int $minimum_log_level, array $blacklist, array $whitelist, EntityManagerInterface $em, Security $security)
+ public function __construct(int $minimum_log_level, protected array $blacklist, protected array $whitelist, protected EntityManagerInterface $em, protected Security $security, protected ConsoleInfoHelper $console_info_helper)
{
- $this->minimum_log_level = $minimum_log_level;
- $this->blacklist = $blacklist;
- $this->whitelist = $whitelist;
- $this->em = $em;
- $this->security = $security;
+ $this->minimum_log_level = LogLevel::tryFrom($minimum_log_level);
}
/**
@@ -54,19 +52,24 @@ class EventLogger
{
$user = $this->security->getUser();
//If the user is not specified explicitly, set it to the current user
- if ((null === $user || $user instanceof User) && null === $logEntry->getUser()) {
- if (null === $user) {
+ if ((!$user instanceof UserInterface || $user instanceof User) && !$logEntry->getUser() instanceof User) {
+ if (!$user instanceof User) {
$repo = $this->em->getRepository(User::class);
$user = $repo->getAnonymousUser();
}
//If no anonymous user is available skip the log (needed for data fixtures)
- if (null === $user) {
+ if (!$user instanceof User) {
return false;
}
$logEntry->setUser($user);
}
+ //Set the console user info, if the log entry was created in a console command
+ if ($this->console_info_helper->isCLI()) {
+ $logEntry->setCLIUsername($this->console_info_helper->getCLIUser() ?? 'Unknown');
+ }
+
if ($this->shouldBeAdded($logEntry)) {
$this->em->persist($logEntry);
@@ -79,25 +82,23 @@ class EventLogger
/**
* Same as log(), but this function can be safely called from within the onFlush() doctrine event, as it
* updated the changesets of the unit of work.
- * @param AbstractLogEntry $logEntry
- * @return bool
*/
public function logFromOnFlush(AbstractLogEntry $logEntry): bool
{
if ($this->log($logEntry)) {
$uow = $this->em->getUnitOfWork();
//As we call it from onFlush, we have to recompute the changeset here, according to https://www.doctrine-project.org/projects/doctrine-orm/en/2.14/reference/events.html#reference-events-on-flush
- $uow->computeChangeSet($this->em->getClassMetadata(get_class($logEntry)), $logEntry);
+ $uow->computeChangeSet($this->em->getClassMetadata($logEntry::class), $logEntry);
return true;
}
- //If the normal log function does not added the log entry, we just do nothing
+ //If the normal log function does not get added to the log entry, we just do nothing
return false;
}
/**
- * Adds the given log entry to the Log, if the entry fullfills the global configured criterias and flush afterwards.
+ * Adds the given log entry to the Log, if the entry fulfills the global configured criteria and flush afterward.
*
* @return bool returns true, if the event was added to log
*/
@@ -111,32 +112,27 @@ class EventLogger
public function shouldBeAdded(
AbstractLogEntry $logEntry,
- ?int $minimum_log_level = null,
+ ?LogLevel $minimum_log_level = null,
?array $blacklist = null,
?array $whitelist = null
): bool {
//Apply the global settings, if nothing was specified
- $minimum_log_level = $minimum_log_level ?? $this->minimum_log_level;
- $blacklist = $blacklist ?? $this->blacklist;
- $whitelist = $whitelist ?? $this->whitelist;
+ $minimum_log_level ??= $this->minimum_log_level;
+ $blacklist ??= $this->blacklist;
+ $whitelist ??= $this->whitelist;
- //Dont add the entry if it does not reach the minimum level
- if ($logEntry->getLevel() > $minimum_log_level) {
+ //Don't add the entry if it does not reach the minimum level
+ if ($logEntry->getLevel()->lessImportThan($minimum_log_level)) {
return false;
}
- //Check if the event type is black listed
- if (!empty($blacklist) && $this->isObjectClassInArray($logEntry, $blacklist)) {
+ //Check if the event type is blacklisted
+ if ($blacklist !== [] && $this->isObjectClassInArray($logEntry, $blacklist)) {
return false;
}
-
//Check for whitelisting
- if (!empty($whitelist) && !$this->isObjectClassInArray($logEntry, $whitelist)) {
- return false;
- }
-
- // By default all things should be added
- return true;
+ // By default, all things should be added
+ return !($whitelist !== [] && !$this->isObjectClassInArray($logEntry, $whitelist));
}
/**
@@ -148,13 +144,13 @@ class EventLogger
protected function isObjectClassInArray(object $object, array $classes): bool
{
//Check if the class is directly in the classes array
- if (in_array(get_class($object), $classes, true)) {
+ if (in_array($object::class, $classes, true)) {
return true;
}
//Iterate over all classes and check for inheritance
foreach ($classes as $class) {
- if (is_a($object, $class)) {
+ if ($object instanceof $class) {
return true;
}
}
diff --git a/src/Services/LogSystem/EventUndoHelper.php b/src/Services/LogSystem/EventUndoHelper.php
index 3dd65eb1..c57f7724 100644
--- a/src/Services/LogSystem/EventUndoHelper.php
+++ b/src/Services/LogSystem/EventUndoHelper.php
@@ -42,33 +42,22 @@ declare(strict_types=1);
namespace App\Services\LogSystem;
use App\Entity\LogSystem\AbstractLogEntry;
-use InvalidArgumentException;
class EventUndoHelper
{
- public const MODE_UNDO = 'undo';
- public const MODE_REVERT = 'revert';
-
- protected const ALLOWED_MODES = [self::MODE_REVERT, self::MODE_UNDO];
-
- protected ?AbstractLogEntry $undone_event;
- protected string $mode;
+ protected ?AbstractLogEntry $undone_event = null;
+ protected EventUndoMode $mode = EventUndoMode::UNDO;
public function __construct()
{
- $this->undone_event = null;
- $this->mode = self::MODE_UNDO;
}
- public function setMode(string $mode): void
+ public function setMode(EventUndoMode $mode): void
{
- if (!in_array($mode, self::ALLOWED_MODES, true)) {
- throw new InvalidArgumentException('Invalid mode passed!');
- }
$this->mode = $mode;
}
- public function getMode(): string
+ public function getMode(): EventUndoMode
{
return $this->mode;
}
@@ -91,7 +80,7 @@ class EventUndoHelper
}
/**
- * Clear the currently the set undone event.
+ * Clear the currently set undone event.
*/
public function clearUndoneEvent(): void
{
@@ -99,7 +88,7 @@ class EventUndoHelper
}
/**
- * Check if a event is undone.
+ * Check if an event is undone.
*/
public function isUndo(): bool
{
diff --git a/src/Services/LogSystem/EventUndoMode.php b/src/Services/LogSystem/EventUndoMode.php
new file mode 100644
index 00000000..de30dcfd
--- /dev/null
+++ b/src/Services/LogSystem/EventUndoMode.php
@@ -0,0 +1,48 @@
+.
+ */
+namespace App\Services\LogSystem;
+
+use InvalidArgumentException;
+
+enum EventUndoMode: string
+{
+ case UNDO = 'undo';
+ case REVERT = 'revert';
+
+ public function toExtraInt(): int
+ {
+ return match ($this) {
+ self::UNDO => 1,
+ self::REVERT => 2,
+ };
+ }
+
+ public static function fromExtraInt(int $int): self
+ {
+ return match ($int) {
+ 1 => self::UNDO,
+ 2 => self::REVERT,
+ default => throw new InvalidArgumentException('Invalid int ' . (string) $int . ' for EventUndoMode'),
+ };
+ }
+}
diff --git a/src/Services/LogSystem/HistoryHelper.php b/src/Services/LogSystem/HistoryHelper.php
index e1638f41..3a31f127 100644
--- a/src/Services/LogSystem/HistoryHelper.php
+++ b/src/Services/LogSystem/HistoryHelper.php
@@ -44,7 +44,6 @@ namespace App\Services\LogSystem;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
-use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project;
@@ -55,10 +54,10 @@ class HistoryHelper
}
/**
- * Returns an array containing all elements that are associated with the argument.
- * The returned array contains the given element.
+ * Returns an array containing all elements that are associated with the argument.
+ * The returned array contains the given element.
*
- * @psalm-return array
+ * @return AbstractDBElement[]
*/
public function getAssociatedElements(AbstractDBElement $element): array
{
diff --git a/src/Services/LogSystem/LogDataFormatter.php b/src/Services/LogSystem/LogDataFormatter.php
new file mode 100644
index 00000000..af54c60c
--- /dev/null
+++ b/src/Services/LogSystem/LogDataFormatter.php
@@ -0,0 +1,153 @@
+.
+ */
+namespace App\Services\LogSystem;
+
+use App\Entity\LogSystem\AbstractLogEntry;
+use App\Services\ElementTypeNameGenerator;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+class LogDataFormatter
+{
+ private const STRING_MAX_LENGTH = 1024;
+
+ public function __construct(private readonly TranslatorInterface $translator, private readonly EntityManagerInterface $entityManager, private readonly ElementTypeNameGenerator $elementTypeNameGenerator)
+ {
+ }
+
+ /**
+ * Formats the given data of a log entry as HTML
+ */
+ public function formatData(mixed $data, AbstractLogEntry $logEntry, string $fieldName): string
+ {
+ if (is_string($data)) {
+ $tmp = '"' . mb_strimwidth(htmlspecialchars($data), 0, self::STRING_MAX_LENGTH) . '"';
+
+ //Show special characters and line breaks
+ $tmp = preg_replace('/\n/', '\\n ', $tmp);
+ $tmp = preg_replace('/\r/', '\\r', $tmp);
+
+ return preg_replace('/\t/', '\\t', $tmp);
+ }
+
+ if (is_bool($data)) {
+ return $this->formatBool($data);
+ }
+
+ if (is_int($data)) {
+ return (string) $data;
+ }
+
+ if (is_float($data)) {
+ return (string) $data;
+ }
+
+ if (is_null($data)) {
+ return 'null';
+ }
+
+ if (is_array($data)) {
+ //If the array contains only one element with the key @id, it is a reference to another entity (foreign key)
+ if (isset($data['@id'])) {
+ return $this->formatForeignKey($data, $logEntry, $fieldName);
+ }
+
+ //If the array contains a "date", "timezone_type" and "timezone" key, it is a DateTime object
+ if (isset($data['date'], $data['timezone_type'], $data['timezone'])) {
+ return $this->formatDateTime($data);
+ }
+
+
+ return $this->formatJSON($data);
+ }
+
+
+ throw new \RuntimeException('Type of $data not supported (' . gettype($data) . ')');
+ }
+
+ private function formatJSON(array $data): string
+ {
+ $json = htmlspecialchars(json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), ENT_QUOTES | ENT_SUBSTITUTE);
+
+ return sprintf(
+ '',
+ $json
+ );
+ }
+
+ private function formatForeignKey(array $data, AbstractLogEntry $logEntry, string $fieldName): string
+ {
+ //Extract the id from the @id key
+ $id = $data['@id'];
+
+ try {
+ //Retrieve the class type from the logEntry and retrieve the doctrine metadata
+ $classMetadata = $this->entityManager->getClassMetadata($logEntry->getTargetClass());
+ $fkTargetClass = $classMetadata->getAssociationTargetClass($fieldName);
+
+ //Try to retrieve the entity from the database
+ $entity = $this->entityManager->getRepository($fkTargetClass)->find($id);
+
+ //If the entity was found, return a label for this entity
+ if ($entity) {
+ return $this->elementTypeNameGenerator->formatLabelHTMLForEntity($entity, true);
+ } else { //Otherwise the entity was deleted, so return the id
+ return $this->elementTypeNameGenerator->formatElementDeletedHTML($fkTargetClass, $id);
+ }
+
+
+ } catch (\InvalidArgumentException|\ReflectionException) {
+ return 'unknown target class: ' . $id;
+ }
+ }
+
+ private function formatDateTime(array $data): string
+ {
+ if (!isset($data['date'], $data['timezone_type'], $data['timezone'])) {
+ return 'unknown DateTime format';
+ }
+
+ $date = $data['date'];
+ $timezoneType = $data['timezone_type'];
+ $timezone = $data['timezone'];
+
+ if (!is_string($date) || !is_int($timezoneType) || !is_string($timezone)) {
+ return 'unknown DateTime format';
+ }
+
+ try {
+ $dateTime = new \DateTimeImmutable($date, new \DateTimeZone($timezone));
+ } catch (\Exception) {
+ return 'unknown DateTime format';
+ }
+
+ //Format it to the users locale
+ $formatter = new \IntlDateFormatter(null, \IntlDateFormatter::MEDIUM, \IntlDateFormatter::MEDIUM);
+ return $formatter->format($dateTime);
+ }
+
+ private function formatBool(bool $data): string
+ {
+ return $data ? $this->translator->trans('true') : $this->translator->trans('false');
+ }
+}
diff --git a/src/Services/LogSystem/LogDiffFormatter.php b/src/Services/LogSystem/LogDiffFormatter.php
new file mode 100644
index 00000000..8b165d5a
--- /dev/null
+++ b/src/Services/LogSystem/LogDiffFormatter.php
@@ -0,0 +1,81 @@
+.
+ */
+namespace App\Services\LogSystem;
+
+use Jfcherng\Diff\DiffHelper;
+
+class LogDiffFormatter
+{
+ /**
+ * Format the diff between the given data, depending on the type of the data.
+ * If the diff is not possible, an empty string is returned.
+ * @param $old_data
+ * @param $new_data
+ */
+ public function formatDiff($old_data, $new_data): string
+ {
+ if (is_string($old_data) && is_string($new_data)) {
+ return $this->diffString($old_data, $new_data);
+ }
+
+ if (is_numeric($old_data) && is_numeric($new_data)) {
+ return $this->diffNumeric($old_data, $new_data);
+ }
+
+ return '';
+ }
+
+ private function diffString(string $old_data, string $new_data): string
+ {
+ return DiffHelper::calculate($old_data, $new_data, 'Combined',
+ [ //Diff options
+ 'context' => 2,
+ ],
+ [ //Render options
+ 'detailLevel' => 'char',
+ 'showHeader' => false,
+ ]);
+ }
+
+ /**
+ * @param numeric $old_data
+ * @param numeric $new_data
+ */
+ private function diffNumeric(int|float|string $old_data, int|float|string $new_data): string
+ {
+ if ((!is_numeric($old_data)) || (!is_numeric($new_data))) {
+ throw new \InvalidArgumentException('The given data is not numeric.');
+ }
+
+ $difference = $new_data - $old_data;
+
+ //Positive difference
+ if ($difference > 0) {
+ return sprintf('+%s', $difference);
+ } elseif ($difference < 0) {
+ return sprintf('%s', $difference);
+ } else {
+ return sprintf('%s', $difference);
+ }
+ }
+}
diff --git a/src/Services/LogSystem/LogEntryExtraFormatter.php b/src/Services/LogSystem/LogEntryExtraFormatter.php
index bfdaf379..ae2a5eba 100644
--- a/src/Services/LogSystem/LogEntryExtraFormatter.php
+++ b/src/Services/LogSystem/LogEntryExtraFormatter.php
@@ -33,6 +33,7 @@ use App\Entity\LogSystem\ElementEditedLogEntry;
use App\Entity\LogSystem\ExceptionLogEntry;
use App\Entity\LogSystem\LegacyInstockChangedLogEntry;
use App\Entity\LogSystem\PartStockChangedLogEntry;
+use App\Entity\LogSystem\PartStockChangeType;
use App\Entity\LogSystem\SecurityEventLogEntry;
use App\Entity\LogSystem\UserLoginLogEntry;
use App\Entity\LogSystem\UserLogoutLogEntry;
@@ -48,17 +49,13 @@ class LogEntryExtraFormatter
{
protected const CONSOLE_SEARCH = ['', '', '', '', ''];
protected const CONSOLE_REPLACE = ['→', '', '', '', ''];
- protected TranslatorInterface $translator;
- protected ElementTypeNameGenerator $elementTypeNameGenerator;
- public function __construct(TranslatorInterface $translator, ElementTypeNameGenerator $elementTypeNameGenerator)
+ public function __construct(protected TranslatorInterface $translator, protected ElementTypeNameGenerator $elementTypeNameGenerator)
{
- $this->translator = $translator;
- $this->elementTypeNameGenerator = $elementTypeNameGenerator;
}
/**
- * Return an user viewable representation of the extra data in a log entry, styled for console output.
+ * Return a user viewable representation of the extra data in a log entry, styled for console output.
*/
public function formatConsole(AbstractLogEntry $logEntry): string
{
@@ -72,7 +69,7 @@ class LogEntryExtraFormatter
$str .= ''.$this->translator->trans($key).': ';
}
$str .= $value;
- if (!empty($str)) {
+ if ($str !== '') {
$tmp[] = $str;
}
}
@@ -81,7 +78,7 @@ class LogEntryExtraFormatter
}
/**
- * Return a HTML formatted string containing a user viewable form of the Extra data.
+ * Return an HTML formatted string containing a user viewable form of the Extra data.
*/
public function format(AbstractLogEntry $context): string
{
@@ -95,7 +92,7 @@ class LogEntryExtraFormatter
$str .= ''.$this->translator->trans($key).': ';
}
$str .= $value;
- if (!empty($str)) {
+ if ($str !== '') {
$tmp[] = $str;
}
}
@@ -130,15 +127,15 @@ class LogEntryExtraFormatter
}
if (($context instanceof LogWithEventUndoInterface) && $context->isUndoEvent()) {
- if ('undo' === $context->getUndoMode()) {
- $array['log.undo_mode.undo'] = (string) $context->getUndoEventID();
- } elseif ('revert' === $context->getUndoMode()) {
- $array['log.undo_mode.revert'] = (string) $context->getUndoEventID();
+ if (EventUndoMode::UNDO === $context->getUndoMode()) {
+ $array['log.undo_mode.undo'] = '#' . $context->getUndoEventID();
+ } elseif (EventUndoMode::REVERT === $context->getUndoMode()) {
+ $array['log.undo_mode.revert'] = '#' . $context->getUndoEventID();
}
}
if ($context instanceof LogWithCommentInterface && $context->hasComment()) {
- $array[] = htmlspecialchars($context->getComment());
+ $array[] = htmlspecialchars((string) $context->getComment());
}
if ($context instanceof ElementCreatedLogEntry && $context->hasCreationInstockValue()) {
@@ -163,7 +160,7 @@ class LogEntryExtraFormatter
'%s %s (%s)',
$context->getOldInstock(),
$context->getNewInstock(),
- (!$context->isWithdrawal() ? '+' : '-').$context->getDifference(true)
+ ($context->isWithdrawal() ? '-' : '+').$context->getDifference(true)
);
$array['log.instock_changed.comment'] = htmlspecialchars($context->getComment());
}
@@ -188,14 +185,18 @@ class LogEntryExtraFormatter
$context->getNewStock(),
($context->getNewStock() > $context->getOldStock() ? '+' : '-'). $context->getChangeAmount(),
);
- if (!empty($context->getComment())) {
+ if ($context->getComment() !== '') {
$array['log.part_stock_changed.comment'] = htmlspecialchars($context->getComment());
}
- if ($context->getInstockChangeType() === PartStockChangedLogEntry::TYPE_MOVE) {
+ if ($context->getInstockChangeType() === PartStockChangeType::MOVE) {
$array['log.part_stock_changed.move_target'] =
- $this->elementTypeNameGenerator->getLocalizedTypeLabel(PartLot::class)
+ htmlspecialchars($this->elementTypeNameGenerator->getLocalizedTypeLabel(PartLot::class))
.' ' . $context->getMoveToTargetID();
}
+ if ($context->getActionTimestamp() !== null) {
+ $formatter = new \IntlDateFormatter($this->translator->getLocale(), \IntlDateFormatter::SHORT, \IntlDateFormatter::SHORT);
+ $array['log.part_stock_changed.timestamp'] = $formatter->format($context->getActionTimestamp());
+ }
}
return $array;
diff --git a/src/Services/LogSystem/LogLevelHelper.php b/src/Services/LogSystem/LogLevelHelper.php
new file mode 100644
index 00000000..67e87392
--- /dev/null
+++ b/src/Services/LogSystem/LogLevelHelper.php
@@ -0,0 +1,64 @@
+.
+ */
+namespace App\Services\LogSystem;
+
+use Psr\Log\LogLevel;
+
+class LogLevelHelper
+{
+ /**
+ * Returns the FontAwesome icon class for the given log level.
+ * This returns just the specific icon class (so 'fa-info' for example).
+ * @param string $logLevel The string representation of the log level (one of the LogLevel::* constants)
+ */
+ public function logLevelToIconClass(string $logLevel): string
+ {
+ return match ($logLevel) {
+ LogLevel::DEBUG => 'fa-bug',
+ LogLevel::INFO => 'fa-info',
+ LogLevel::NOTICE => 'fa-flag',
+ LogLevel::WARNING => 'fa-exclamation-circle',
+ LogLevel::ERROR => 'fa-exclamation-triangle',
+ LogLevel::CRITICAL => 'fa-bolt',
+ LogLevel::ALERT => 'fa-radiation',
+ LogLevel::EMERGENCY => 'fa-skull-crossbones',
+ default => 'fa-question-circle',
+ };
+ }
+
+ /**
+ * Returns the Bootstrap table color class for the given log level.
+ * @param string $logLevel The string representation of the log level (one of the LogLevel::* constants)
+ * @return string The table color class (one of the 'table-*' classes)
+ */
+ public function logLevelToTableColorClass(string $logLevel): string
+ {
+
+ return match ($logLevel) {
+ LogLevel::EMERGENCY, LogLevel::ALERT, LogLevel::CRITICAL, LogLevel::ERROR => 'table-danger',
+ LogLevel::WARNING => 'table-warning',
+ LogLevel::NOTICE => 'table-info',
+ default => '',
+ };
+ }
+}
diff --git a/src/Services/LogSystem/LogTargetHelper.php b/src/Services/LogSystem/LogTargetHelper.php
new file mode 100644
index 00000000..5dd649f1
--- /dev/null
+++ b/src/Services/LogSystem/LogTargetHelper.php
@@ -0,0 +1,79 @@
+.
+ */
+namespace App\Services\LogSystem;
+
+use App\Entity\Base\AbstractDBElement;
+use App\Entity\LogSystem\AbstractLogEntry;
+use App\Entity\LogSystem\UserNotAllowedLogEntry;
+use App\Repository\LogEntryRepository;
+use App\Services\ElementTypeNameGenerator;
+use App\Services\EntityURLGenerator;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+use Symfony\Contracts\Translation\TranslatorInterface;
+
+class LogTargetHelper
+{
+ protected LogEntryRepository $entryRepository;
+
+ public function __construct(protected EntityManagerInterface $em, protected EntityURLGenerator $entityURLGenerator,
+ protected ElementTypeNameGenerator $elementTypeNameGenerator, protected TranslatorInterface $translator)
+ {
+ $this->entryRepository = $em->getRepository(AbstractLogEntry::class);
+ }
+
+ private function configureOptions(OptionsResolver $resolver): self
+ {
+ $resolver->setDefault('show_associated', true);
+ $resolver->setDefault('showAccessDeniedPath', true);
+
+ return $this;
+ }
+
+ public function formatTarget(AbstractLogEntry $context, array $options = []): string
+ {
+ $optionsResolver = new OptionsResolver();
+ $this->configureOptions($optionsResolver);
+ $options = $optionsResolver->resolve($options);
+
+ if ($context instanceof UserNotAllowedLogEntry && $options['showAccessDeniedPath']) {
+ return htmlspecialchars($context->getPath());
+ }
+
+ /** @var AbstractLogEntry $context */
+ $target = $this->entryRepository->getTargetElement($context);
+
+ //If the target is null and the context has a target, that means that the target was deleted. Show it that way.
+ if (!$target instanceof AbstractDBElement) {
+ if ($context->hasTarget()) {
+ return $this->elementTypeNameGenerator->formatElementDeletedHTML($context->getTargetClass(),
+ $context->getTargetID());
+ }
+ //If no target is set, we can't do anything
+ return '';
+ }
+
+ //Otherwise we can return a label for the target
+ return $this->elementTypeNameGenerator->formatLabelHTMLForEntity($target, $options['show_associated']);
+ }
+}
diff --git a/src/Services/LogSystem/TimeTravel.php b/src/Services/LogSystem/TimeTravel.php
index 9933d235..68d962bb 100644
--- a/src/Services/LogSystem/TimeTravel.php
+++ b/src/Services/LogSystem/TimeTravel.php
@@ -34,23 +34,21 @@ use App\Repository\LogEntryRepository;
use Brick\Math\BigDecimal;
use DateTime;
use Doctrine\Common\Collections\Collection;
+use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
-use Doctrine\ORM\Mapping\ClassMetadataInfo;
use Doctrine\ORM\Mapping\MappingException;
-use DoctrineExtensions\Query\Mysql\Date;
use Exception;
use InvalidArgumentException;
use ReflectionClass;
+use Symfony\Component\PropertyAccess\PropertyAccessor;
class TimeTravel
{
- protected EntityManagerInterface $em;
protected LogEntryRepository $repo;
- public function __construct(EntityManagerInterface $em)
+ public function __construct(protected EntityManagerInterface $em)
{
- $this->em = $em;
$this->repo = $em->getRepository(AbstractLogEntry::class);
}
@@ -58,8 +56,11 @@ class TimeTravel
/**
* Undeletes the element with the given ID.
*
+ * @template T of AbstractDBElement
* @param string $class The class name of the element that should be undeleted
+ * @phpstan-param class-string $class
* @param int $id the ID of the element that should be undeleted
+ * @phpstan-return T
*/
public function undeleteEntity(string $class, int $id): AbstractDBElement
{
@@ -83,7 +84,7 @@ class TimeTravel
*
* @throws Exception
*/
- public function revertEntityToTimestamp(AbstractDBElement $element, DateTime $timestamp, array $reverted_elements = []): void
+ public function revertEntityToTimestamp(AbstractDBElement $element, \DateTimeInterface $timestamp, array $reverted_elements = []): void
{
if (!$element instanceof TimeStampableInterface) {
throw new InvalidArgumentException('$element must have a Timestamp!');
@@ -128,7 +129,7 @@ class TimeTravel
}
// Revert any of the associated elements
- $metadata = $this->em->getClassMetadata(get_class($element));
+ $metadata = $this->em->getClassMetadata($element::class);
$associations = $metadata->getAssociationMappings();
foreach ($associations as $field => $mapping) {
if (
@@ -138,27 +139,27 @@ class TimeTravel
continue;
}
- //Revert many to one association (one element in property)
+ //Revert many-to-one association (one element in property)
if (
- ClassMetadataInfo::MANY_TO_ONE === $mapping['type']
- || ClassMetadataInfo::ONE_TO_ONE === $mapping['type']
+ ClassMetadata::MANY_TO_ONE === $mapping['type']
+ || ClassMetadata::ONE_TO_ONE === $mapping['type']
) {
$target_element = $this->getField($element, $field);
if (null !== $target_element && $element->getLastModified() > $timestamp) {
$this->revertEntityToTimestamp($target_element, $timestamp, $reverted_elements);
}
} elseif ( //Revert *_TO_MANY associations (collection properties)
- (ClassMetadataInfo::MANY_TO_MANY === $mapping['type']
- || ClassMetadataInfo::ONE_TO_MANY === $mapping['type'])
- && false === $mapping['isOwningSide']
+ (ClassMetadata::MANY_TO_MANY === $mapping['type']
+ || ClassMetadata::ONE_TO_MANY === $mapping['type'])
+ && !$mapping['isOwningSide']
) {
$target_elements = $this->getField($element, $field);
- if (null === $target_elements || count($target_elements) > 10) {
+ if (null === $target_elements || (is_countable($target_elements) ? count($target_elements) : 0) > 10) {
continue;
}
foreach ($target_elements as $target_element) {
if (null !== $target_element && $element->getLastModified() >= $timestamp) {
- //Remove the element from collection, if it did not existed at $timestamp
+ //Remove the element from collection, if it did not exist at $timestamp
if (!$this->repo->getElementExistedAtTimestamp(
$target_element,
$timestamp
@@ -175,17 +176,26 @@ class TimeTravel
/**
* This function decodes the array which is created during the json_encode of a datetime object and returns a DateTime object.
* @param array $input
- * @return DateTime
+ * @return \DateTimeInterface
* @throws Exception
*/
- private function dateTimeDecode(?array $input): ?\DateTime
+ private function dateTimeDecode(?array $input, string $doctrineType): ?\DateTimeInterface
{
//Allow null values
if ($input === null) {
return null;
}
- return new \DateTime($input['date'], new \DateTimeZone($input['timezone']));
+ //Mutable types
+ if (in_array($doctrineType, [Types::DATETIME_MUTABLE, Types::DATE_MUTABLE], true)) {
+ return new \DateTime($input['date'], new \DateTimeZone($input['timezone']));
+ }
+ //Immutable types
+ if (in_array($doctrineType, [Types::DATETIME_IMMUTABLE, Types::DATE_IMMUTABLE], true)) {
+ return new \DateTimeImmutable($input['date'], new \DateTimeZone($input['timezone']));
+ }
+
+ throw new InvalidArgumentException('The given doctrine type is not a datetime type!');
}
/**
@@ -196,24 +206,34 @@ class TimeTravel
public function applyEntry(AbstractDBElement $element, TimeTravelInterface $logEntry): void
{
//Skip if this does not provide any info...
- if (!$logEntry->hasOldDataInformations()) {
+ if (!$logEntry->hasOldDataInformation()) {
return;
}
if (!$element instanceof TimeStampableInterface) {
return;
}
- $metadata = $this->em->getClassMetadata(get_class($element));
+ $metadata = $this->em->getClassMetadata($element::class);
$old_data = $logEntry->getOldData();
foreach ($old_data as $field => $data) {
- if ($metadata->hasField($field)) {
+
+ //We use the fieldMappings property directly instead of the hasField method, as we do not want to match the embedded field itself
+ //The sub fields are handled in the setField method
+ if (isset($metadata->fieldMappings[$field])) {
//We need to convert the string to a BigDecimal first
- if (!$data instanceof BigDecimal && ('big_decimal' === $metadata->getFieldMapping($field)['type'])) {
+ if (!$data instanceof BigDecimal && ('big_decimal' === $metadata->getFieldMapping($field)->type)) {
$data = BigDecimal::of($data);
}
- if (!$data instanceof DateTime && ('datetime' === $metadata->getFieldMapping($field)['type'])) {
- $data = $this->dateTimeDecode($data);
+ if (!$data instanceof \DateTimeInterface
+ && (in_array($metadata->getFieldMapping($field)->type,
+ [
+ Types::DATETIME_IMMUTABLE,
+ Types::DATETIME_IMMUTABLE,
+ Types::DATE_MUTABLE,
+ Types::DATETIME_IMMUTABLE
+ ], true))) {
+ $data = $this->dateTimeDecode($data, $metadata->getFieldMapping($field)->type);
}
$this->setField($element, $field, $data);
@@ -223,7 +243,7 @@ class TimeTravel
$target_class = $mapping['targetEntity'];
//Try to extract the old ID:
if (is_array($data) && isset($data['@id'])) {
- $entity = $this->em->getPartialReference($target_class, $data['@id']);
+ $entity = $this->em->getReference($target_class, $data['@id']);
$this->setField($element, $field, $entity);
}
}
@@ -232,24 +252,45 @@ class TimeTravel
$this->setField($element, 'lastModified', $logEntry->getTimestamp());
}
- protected function getField(AbstractDBElement $element, string $field)
+ protected function getField(AbstractDBElement $element, string $field): mixed
{
- $reflection = new ReflectionClass(get_class($element));
+ $reflection = new ReflectionClass($element::class);
$property = $reflection->getProperty($field);
- $property->setAccessible(true);
return $property->getValue($element);
}
/**
- * @param DateTime|int|null $new_value
+ * @param int|null|object $new_value
*/
- protected function setField(AbstractDBElement $element, string $field, $new_value): void
+ protected function setField(AbstractDBElement $element, string $field, mixed $new_value): void
{
- $reflection = new ReflectionClass(get_class($element));
- $property = $reflection->getProperty($field);
- $property->setAccessible(true);
+ //If the field name contains a dot, it is a embeddedable object and we need to split the field name
+ if (str_contains($field, '.')) {
+ [$embedded, $embedded_field] = explode('.', $field);
- $property->setValue($element, $new_value);
+ $elementClass = new ReflectionClass($element::class);
+ $property = $elementClass->getProperty($embedded);
+ $embeddedClass = $property->getValue($element);
+
+ $embeddedReflection = new ReflectionClass($embeddedClass::class);
+ $property = $embeddedReflection->getProperty($embedded_field);
+ $target_element = $embeddedClass;
+ } else {
+ $reflection = new ReflectionClass($element::class);
+ $property = $reflection->getProperty($field);
+ $target_element = $element;
+ }
+
+ //Check if the property is an BackedEnum, then convert the int or float value to an enum instance
+ if ((is_string($new_value) || is_int($new_value))
+ && $property->getType() instanceof \ReflectionNamedType
+ && is_a($property->getType()->getName(), \BackedEnum::class, true)) {
+ /** @phpstan-var class-string<\BackedEnum> $enum_class */
+ $enum_class = $property->getType()->getName();
+ $new_value = $enum_class::from($new_value);
+ }
+
+ $property->setValue($target_element, $new_value);
}
}
diff --git a/src/Services/Misc/ConsoleInfoHelper.php b/src/Services/Misc/ConsoleInfoHelper.php
new file mode 100644
index 00000000..98de5e07
--- /dev/null
+++ b/src/Services/Misc/ConsoleInfoHelper.php
@@ -0,0 +1,56 @@
+.
+ */
+namespace App\Services\Misc;
+
+class ConsoleInfoHelper
+{
+ /**
+ * Returns true if the current script is executed in a CLI environment.
+ * @return bool true if the current script is executed in a CLI environment, false otherwise
+ */
+ public function isCLI(): bool
+ {
+ return \in_array(\PHP_SAPI, ['cli', 'phpdbg'], true);
+ }
+
+ /**
+ * Returns the username of the user who started the current script if possible.
+ * @return string|null the username of the user who started the current script if possible, null otherwise
+ * @noinspection PhpUndefinedFunctionInspection
+ */
+ public function getCLIUser(): ?string
+ {
+ if (!$this->isCLI()) {
+ return null;
+ }
+
+ //Try to use the posix extension if available (Linux)
+ if (function_exists('posix_getpwuid') && function_exists('posix_geteuid')) {
+ $user = posix_getpwuid(posix_geteuid());
+ return $user['name'];
+ }
+
+ //Otherwise we can't determine the username
+ return $_SERVER['USERNAME'] ?? $_SERVER['USER'] ?? null;
+ }
+}
diff --git a/src/Services/Misc/FAIconGenerator.php b/src/Services/Misc/FAIconGenerator.php
index b1687f2f..2ea727af 100644
--- a/src/Services/Misc/FAIconGenerator.php
+++ b/src/Services/Misc/FAIconGenerator.php
@@ -24,21 +24,24 @@ namespace App\Services\Misc;
use App\Entity\Attachments\Attachment;
use function in_array;
-use InvalidArgumentException;
+/**
+ * @see \App\Tests\Services\Misc\FAIconGeneratorTest
+ */
class FAIconGenerator
{
protected const EXT_MAPPING = [
- 'fa-file-pdf' => ['pdf'],
+ 'fa-file-pdf' => ['pdf', 'ps', 'eps'],
'fa-file-image' => Attachment::PICTURE_EXTS,
- 'fa-file-alt' => ['txt', 'md', 'rtf', 'log', 'rst', 'tex'],
- 'fa-file-csv' => ['csv'],
- 'fa-file-word' => ['doc', 'docx', 'odt'],
- 'fa-file-archive' => ['zip', 'rar', 'bz2', 'tar', '7z', 'gz'],
- 'fa-file-audio' => ['mp3', 'wav', 'aac', 'm4a', 'wma'],
+ 'fa-file-lines' => ['txt', 'md', 'log', 'rst', 'tex'],
+ 'fa-file-csv' => ['csv', 'tsv'],
+ 'fa-file-word' => ['doc', 'docx', 'odt', 'rtf'],
+ 'fa-file-zipper' => ['zip', 'rar', 'bz2', 'tar', '7z', 'gz', 'tgz', 'xz', 'txz', 'tbz'],
+ 'fa-file-audio' => ['mp3', 'wav', 'aac', 'm4a', 'wma', 'ogg', 'flac', 'alac'],
'fa-file-powerpoint' => ['ppt', 'pptx', 'odp', 'pps', 'key'],
- 'fa-file-excel' => ['xls', 'xlr', 'xlsx', 'ods'],
- 'fa-file-code' => ['php', 'xml', 'html', 'js', 'ts', 'htm', 'c', 'cpp'],
+ 'fa-file-excel' => ['xls', 'xlr', 'xlsx', 'ods', 'numbers'],
+ 'fa-file-code' => ['php', 'xml', 'html', 'js', 'ts', 'htm', 'c', 'cpp', 'json', 'py', 'css', 'yml', 'yaml',
+ 'sql', 'sh', 'bat', 'exe', 'dll', 'lib', 'so', 'a', 'o', 'h', 'hpp', 'java', 'class', 'jar', 'rb', 'rbw', 'rake', 'gem',],
'fa-file-video' => ['webm', 'avi', 'mp4', 'mkv', 'wmv'],
];
@@ -52,9 +55,6 @@ class FAIconGenerator
*/
public function fileExtensionToFAType(string $extension): string
{
- if ('' === $extension) {
- throw new InvalidArgumentException('You must specify an extension!');
- }
//Normalize file extension
$extension = strtolower($extension);
foreach (self::EXT_MAPPING as $fa => $exts) {
diff --git a/src/Services/Misc/RangeParser.php b/src/Services/Misc/RangeParser.php
index ab6e9aba..f1a5db5b 100644
--- a/src/Services/Misc/RangeParser.php
+++ b/src/Services/Misc/RangeParser.php
@@ -45,6 +45,7 @@ use InvalidArgumentException;
/**
* This Parser allows to parse number ranges like 1-3, 4, 5.
+ * @see \App\Tests\Services\Misc\RangeParserTest
*/
class RangeParser
{
@@ -70,7 +71,7 @@ class RangeParser
$ranges[] = $this->generateMinMaxRange($matches[1], $matches[2]);
} elseif (is_numeric($number)) {
$ranges[] = [(int) $number];
- } elseif (empty($number)) { //Allow empty tokens
+ } elseif ($number === '') { //Allow empty tokens
continue;
} else {
throw new InvalidArgumentException('Invalid range encoutered: '.$number);
@@ -94,7 +95,7 @@ class RangeParser
$this->parse($range_str);
return true;
- } catch (InvalidArgumentException $exception) {
+ } catch (InvalidArgumentException) {
return false;
}
}
diff --git a/src/Services/OAuth/OAuthTokenManager.php b/src/Services/OAuth/OAuthTokenManager.php
new file mode 100644
index 00000000..9c22503b
--- /dev/null
+++ b/src/Services/OAuth/OAuthTokenManager.php
@@ -0,0 +1,162 @@
+.
+ */
+
+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 OAuthToken The saved token as database entity
+ */
+ public function saveToken(string $app_name, AccessTokenInterface $token): OAuthToken
+ {
+ //Check if we already have a token for this app
+ $tokenEntity = $this->entityManager->getRepository(OAuthToken::class)->findOneBy(['name' => $app_name]);
+
+ //If the token was already existing, we just replace it with the new one
+ if ($tokenEntity !== null) {
+ $tokenEntity->replaceWithNewToken($token);
+
+ $this->entityManager->flush();
+
+ //We are done
+ return $tokenEntity;
+ }
+
+ //If the token was not existing, we create a new one
+ $tokenEntity = OAuthToken::fromAccessToken($token, $app_name);
+ $this->entityManager->persist($tokenEntity);
+
+ $this->entityManager->flush();
+
+ return $tokenEntity;
+ }
+
+ /**
+ * 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 === null) {
+ throw new \RuntimeException('No token was saved yet for '.$app_name);
+ }
+
+ $client = $this->clientRegistry->getClient($app_name);
+
+ //Check if the token is refreshable or if it is an client credentials token
+ if ($token->isClientCredentialsGrant()) {
+ $new_token = $client->getOAuth2Provider()->getAccessToken('client_credentials');
+ } else {
+ //Otherwise we can use the refresh token to get a new access token
+ $new_token = $client->refreshAccessToken($token->getRefreshToken());
+ }
+
+ //Persist the token
+ $token->replaceWithNewToken($new_token);
+ $this->entityManager->flush();
+
+ 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 === null) {
+ 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();
+ }
+
+ /**
+ * Retrieves an access token for the given app name using the client credentials grant (so no user flow is needed)
+ * The app_name must be registered in the knpu_oauth2_client.yaml
+ * The token is saved to the database, and afterward can be used as usual
+ * @param string $app_name
+ * @return OAuthToken
+ */
+ public function retrieveClientCredentialsToken(string $app_name): OAuthToken
+ {
+ $client = $this->clientRegistry->getClient($app_name);
+ $access_token = $client->getOAuth2Provider()->getAccessToken('client_credentials');
+
+
+ return $this->saveToken($app_name, $access_token);
+ }
+}
\ No newline at end of file
diff --git a/src/Services/Parameters/ParameterExtractor.php b/src/Services/Parameters/ParameterExtractor.php
index de5ecb51..a133b282 100644
--- a/src/Services/Parameters/ParameterExtractor.php
+++ b/src/Services/Parameters/ParameterExtractor.php
@@ -47,6 +47,9 @@ use InvalidArgumentException;
use function preg_match;
+/**
+ * @see \App\Tests\Services\Parameters\ParameterExtractorTest
+ */
class ParameterExtractor
{
protected const ALLOWED_PARAM_SEPARATORS = [', ', "\n"];
@@ -74,7 +77,7 @@ class ParameterExtractor
$split = $this->splitString($input);
foreach ($split as $param_string) {
$tmp = $this->stringToParam($param_string, $class);
- if (null !== $tmp) {
+ if ($tmp instanceof AbstractParameter) {
$parameters[] = $tmp;
}
}
@@ -85,16 +88,16 @@ class ParameterExtractor
protected function stringToParam(string $input, string $class): ?AbstractParameter
{
$input = trim($input);
- $regex = '/^(.*) *(?:=|:) *(.+)/u';
+ $regex = '/^(.*) *(?:=|:)(?!\/) *(.+)/u';
$matches = [];
preg_match($regex, $input, $matches);
- if (!empty($matches)) {
+ if ($matches !== []) {
[, $name, $value] = $matches;
$value = trim($value);
- //Dont allow empty names or values (these are a sign of an invalid extracted string)
- if (empty($name) || empty($value)) {
+ //Don't allow empty names or values (these are a sign of an invalid extracted string)
+ if ($name === '' || $value === '') {
return null;
}
diff --git a/src/Services/Parts/PartLotWithdrawAddHelper.php b/src/Services/Parts/PartLotWithdrawAddHelper.php
index 80403dd4..34ec4c1d 100644
--- a/src/Services/Parts/PartLotWithdrawAddHelper.php
+++ b/src/Services/Parts/PartLotWithdrawAddHelper.php
@@ -1,29 +1,28 @@
eventLogger = $eventLogger;
- $this->eventCommentHelper = $eventCommentHelper;
}
/**
* Checks whether the given part can
- * @param PartLot $partLot
- * @return bool
*/
public function canAdd(PartLot $partLot): bool
{
@@ -33,16 +32,11 @@ final class PartLotWithdrawAddHelper
}
//So far all other restrictions are defined at the storelocation level
- if($partLot->getStorageLocation() === null) {
+ if(!$partLot->getStorageLocation() instanceof StorageLocation) {
return true;
}
-
//We can not add parts if the storage location of the lot is marked as full
- if($partLot->getStorageLocation()->isFull()) {
- return false;
- }
-
- return true;
+ return !$partLot->getStorageLocation()->isFull();
}
public function canWithdraw(PartLot $partLot): bool
@@ -51,13 +45,8 @@ final class PartLotWithdrawAddHelper
if ($partLot->isInstockUnknown()) {
return false;
}
-
//Part must contain more than 0 parts
- if ($partLot->getAmount() <= 0) {
- return false;
- }
-
- return true;
+ return $partLot->getAmount() > 0;
}
/**
@@ -66,9 +55,10 @@ final class PartLotWithdrawAddHelper
* @param PartLot $partLot The partLot from which the instock should be taken (which value should be decreased)
* @param float $amount The amount of parts that should be taken from the part lot
* @param string|null $comment The optional comment describing the reason for the withdrawal
- * @return PartLot The modified part lot
+ * @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
+ * @param bool $delete_lot_if_empty If true, the part lot will be deleted if the amount is 0 after the withdrawal.
*/
- public function withdraw(PartLot $partLot, float $amount, ?string $comment = null): PartLot
+ public function withdraw(PartLot $partLot, float $amount, ?string $comment = null, ?\DateTimeInterface $action_timestamp = null, bool $delete_lot_if_empty = false): void
{
//Ensure that amount is positive
if ($amount <= 0) {
@@ -96,15 +86,17 @@ final class PartLotWithdrawAddHelper
$oldAmount = $partLot->getAmount();
$partLot->setAmount($oldAmount - $amount);
- $event = PartStockChangedLogEntry::withdraw($partLot, $oldAmount, $partLot->getAmount(), $part->getAmountSum() , $comment);
+ $event = PartStockChangedLogEntry::withdraw($partLot, $oldAmount, $partLot->getAmount(), $part->getAmountSum() , $comment, $action_timestamp);
$this->eventLogger->log($event);
//Apply the comment also to global events, so it gets associated with the elementChanged log entry
- if (!$this->eventCommentHelper->isMessageSet() && !empty($comment)) {
+ if (!$this->eventCommentHelper->isMessageSet() && ($comment !== null && $comment !== '')) {
$this->eventCommentHelper->setMessage($comment);
}
- return $partLot;
+ if ($delete_lot_if_empty && $partLot->getAmount() === 0.0) {
+ $this->entityManager->remove($partLot);
+ }
}
/**
@@ -113,9 +105,10 @@ final class PartLotWithdrawAddHelper
* @param PartLot $partLot The partLot from which the instock should be taken (which value should be decreased)
* @param float $amount The amount of parts that should be taken from the part lot
* @param string|null $comment The optional comment describing the reason for the withdrawal
+ * @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
* @return PartLot The modified part lot
*/
- public function add(PartLot $partLot, float $amount, ?string $comment = null): PartLot
+ public function add(PartLot $partLot, float $amount, ?string $comment = null, ?\DateTimeInterface $action_timestamp = null): PartLot
{
if ($amount <= 0) {
throw new \InvalidArgumentException('Amount must be positive');
@@ -136,11 +129,11 @@ final class PartLotWithdrawAddHelper
$oldAmount = $partLot->getAmount();
$partLot->setAmount($oldAmount + $amount);
- $event = PartStockChangedLogEntry::add($partLot, $oldAmount, $partLot->getAmount(), $part->getAmountSum() , $comment);
+ $event = PartStockChangedLogEntry::add($partLot, $oldAmount, $partLot->getAmount(), $part->getAmountSum() , $comment, $action_timestamp);
$this->eventLogger->log($event);
//Apply the comment also to global events, so it gets associated with the elementChanged log entry
- if (!$this->eventCommentHelper->isMessageSet() && !empty($comment)) {
+ if (!$this->eventCommentHelper->isMessageSet() && ($comment !== null && $comment !== '')) {
$this->eventCommentHelper->setMessage($comment);
}
@@ -154,9 +147,10 @@ final class PartLotWithdrawAddHelper
* @param PartLot $target The part lot to which the parts should be added
* @param float $amount The amount of parts that should be moved
* @param string|null $comment A comment describing the reason for the move
- * @return void
+ * @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
+ * @param bool $delete_lot_if_empty If true, the part lot will be deleted if the amount is 0 after the withdrawal.
*/
- public function move(PartLot $origin, PartLot $target, float $amount, ?string $comment = null): void
+ public function move(PartLot $origin, PartLot $target, float $amount, ?string $comment = null, ?\DateTimeInterface $action_timestamp = null, bool $delete_lot_if_empty = false): void
{
if ($amount <= 0) {
throw new \InvalidArgumentException('Amount must be positive');
@@ -191,12 +185,16 @@ final class PartLotWithdrawAddHelper
//And add it to the target
$target->setAmount($target->getAmount() + $amount);
- $event = PartStockChangedLogEntry::move($origin, $oldOriginAmount, $origin->getAmount(), $part->getAmountSum() , $comment, $target);
+ $event = PartStockChangedLogEntry::move($origin, $oldOriginAmount, $origin->getAmount(), $part->getAmountSum() , $comment, $target, $action_timestamp);
$this->eventLogger->log($event);
//Apply the comment also to global events, so it gets associated with the elementChanged log entry
- if (!$this->eventCommentHelper->isMessageSet() && !empty($comment)) {
+ if (!$this->eventCommentHelper->isMessageSet() && ($comment !== null && $comment !== '')) {
$this->eventCommentHelper->setMessage($comment);
}
+
+ if ($delete_lot_if_empty && $origin->getAmount() === 0.0) {
+ $this->entityManager->remove($origin);
+ }
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php
index 8b695141..616df229 100644
--- a/src/Services/Parts/PartsTableActionHandler.php
+++ b/src/Services/Parts/PartsTableActionHandler.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Services\Parts;
+use App\Entity\Parts\StorageLocation;
+use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
-use App\Repository\DBElementRepository;
use App\Repository\PartRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
-use Symfony\Component\Security\Core\Security;
+use Symfony\Contracts\Translation\TranslatableInterface;
+
+use function Symfony\Component\Translation\t;
final class PartsTableActionHandler
{
- private EntityManagerInterface $entityManager;
- private Security $security;
- private UrlGeneratorInterface $urlGenerator;
-
- public function __construct(EntityManagerInterface $entityManager, Security $security, UrlGeneratorInterface $urlGenerator)
+ public function __construct(private readonly EntityManagerInterface $entityManager, private readonly Security $security, private readonly UrlGeneratorInterface $urlGenerator)
{
- $this->entityManager = $entityManager;
- $this->security = $security;
- $this->urlGenerator = $urlGenerator;
}
/**
@@ -59,7 +57,6 @@ final class PartsTableActionHandler
{
$id_array = explode(',', $ids);
- /** @var PartRepository $repo */
$repo = $this->entityManager->getRepository(Part::class);
return $repo->getElementsFromIDArray($id_array);
@@ -68,8 +65,9 @@ final class PartsTableActionHandler
/**
* @param Part[] $selected_parts
* @return RedirectResponse|null Returns a redirect response if the user should be redirected to another page, otherwise null
+ * //@param-out list|array $errors
*/
- public function handleAction(string $action, array $selected_parts, ?int $target_id, ?string $redirect_url = null): ?RedirectResponse
+ public function handleAction(string $action, array $selected_parts, ?int $target_id, ?string $redirect_url = null, array &$errors = []): ?RedirectResponse
{
if ($action === 'add_to_project') {
return new RedirectResponse(
@@ -86,10 +84,8 @@ final class PartsTableActionHandler
if ($action === 'generate_label') {
$targets = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts));
} else { //For lots we have to extract the part lots
- $targets = implode(',', array_map(static function (Part $part) {
- //We concat the lot IDs of every part with a comma (which are later concated with a comma too per part)
- return implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPartLots()->toArray()));
- }, $selected_parts));
+ $targets = implode(',', array_map(static fn(Part $part): string => //We concat the lot IDs of every part with a comma (which are later concated with a comma too per part)
+implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPartLots()->toArray())), $selected_parts));
}
return new RedirectResponse(
@@ -102,6 +98,27 @@ final class PartsTableActionHandler
);
}
+ //When action starts with "export_" we have to redirect to the export controller
+ $matches = [];
+ if (preg_match('/^export_(json|yaml|xml|csv)$/', $action, $matches)) {
+ $ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts));
+ $level = match ($target_id) {
+ 2 => 'extended',
+ 3 => 'full',
+ default => 'simple',
+ };
+
+
+ return new RedirectResponse(
+ $this->urlGenerator->generate('parts_export', [
+ 'format' => $matches[1],
+ 'level' => $level,
+ 'ids' => $ids,
+ '_redirect' => $redirect_url
+ ])
+ );
+ }
+
//Iterate over the parts and apply the action to it:
foreach ($selected_parts as $part) {
@@ -149,6 +166,29 @@ final class PartsTableActionHandler
$this->denyAccessUnlessGranted('@measurement_units.read');
$part->setPartUnit(null === $target_id ? null : $this->entityManager->find(MeasurementUnit::class, $target_id));
break;
+ case 'change_location':
+ $this->denyAccessUnlessGranted('@storelocations.read');
+ //Retrieve the first part lot and set the location for it
+ $part_lots = $part->getPartLots();
+ if ($part_lots->count() > 0) {
+ if ($part_lots->count() > 1) {
+ $errors[] = [
+ 'part' => $part,
+ 'message' => t('parts.table.action_handler.error.part_lots_multiple'),
+ ];
+ break;
+ }
+
+ $part_lot = $part_lots->first();
+ $part_lot->setStorageLocation(null === $target_id ? null : $this->entityManager->find(StorageLocation::class, $target_id));
+ } else { //Create a new part lot if there are none
+ $part_lot = new PartLot();
+ $part_lot->setPart($part);
+ $part_lot->setInstockUnknown(true); //We do not know how many parts are in stock, so we set it to true
+ $part_lot->setStorageLocation(null === $target_id ? null : $this->entityManager->find(StorageLocation::class, $target_id));
+ $this->entityManager->persist($part_lot);
+ }
+ break;
default:
throw new InvalidArgumentException('The given action is unknown! ('.$action.')');
@@ -164,7 +204,7 @@ final class PartsTableActionHandler
*
* @throws AccessDeniedException
*/
- private function denyAccessUnlessGranted($attributes, $subject = null, string $message = 'Access Denied.'): void
+ private function denyAccessUnlessGranted(mixed $attributes, mixed $subject = null, string $message = 'Access Denied.'): void
{
if (!$this->security->isGranted($attributes, $subject)) {
$exception = new AccessDeniedException($message);
diff --git a/src/Services/Parts/PricedetailHelper.php b/src/Services/Parts/PricedetailHelper.php
index 464034a6..092cc278 100644
--- a/src/Services/Parts/PricedetailHelper.php
+++ b/src/Services/Parts/PricedetailHelper.php
@@ -32,14 +32,15 @@ use Locale;
use function count;
+/**
+ * @see \App\Tests\Services\Parts\PricedetailHelperTest
+ */
class PricedetailHelper
{
- protected string $base_currency;
protected string $locale;
- public function __construct(string $base_currency)
+ public function __construct(protected string $base_currency)
{
- $this->base_currency = $base_currency;
$this->locale = Locale::getDefault();
}
@@ -56,7 +57,7 @@ class PricedetailHelper
foreach ($orderdetails as $orderdetail) {
$pricedetails = $orderdetail->getPricedetails();
//The orderdetail must have pricedetails, otherwise this will not work!
- if (0 === count($pricedetails)) {
+ if (0 === (is_countable($pricedetails) ? count($pricedetails) : 0)) {
continue;
}
@@ -67,9 +68,7 @@ class PricedetailHelper
} else {
// We have to sort the pricedetails manually
$array = $pricedetails->map(
- static function (Pricedetail $pricedetail) {
- return $pricedetail->getMinDiscountQuantity();
- }
+ static fn(Pricedetail $pricedetail) => $pricedetail->getMinDiscountQuantity()
)->toArray();
sort($array);
$max_amount = end($array);
@@ -103,7 +102,7 @@ class PricedetailHelper
foreach ($orderdetails as $orderdetail) {
$pricedetails = $orderdetail->getPricedetails();
//The orderdetail must have pricedetails, otherwise this will not work!
- if (0 === count($pricedetails)) {
+ if (0 === (is_countable($pricedetails) ? count($pricedetails) : 0)) {
continue;
}
@@ -153,14 +152,14 @@ class PricedetailHelper
foreach ($orderdetails as $orderdetail) {
$pricedetail = $orderdetail->findPriceForQty($amount);
- //When we dont have informations about this amount, ignore it
- if (null === $pricedetail) {
+ //When we don't have information about this amount, ignore it
+ if (!$pricedetail instanceof Pricedetail) {
continue;
}
$converted = $this->convertMoneyToCurrency($pricedetail->getPricePerUnit(), $pricedetail->getCurrency(), $currency);
- //Ignore price informations that can not be converted to base currency.
- if (null !== $converted) {
+ //Ignore price information that can not be converted to base currency.
+ if ($converted instanceof BigDecimal) {
$avg = $avg->plus($converted);
++$count;
}
@@ -170,7 +169,7 @@ class PricedetailHelper
return null;
}
- return $avg->dividedBy($count)->toScale(Pricedetail::PRICE_PRECISION, RoundingMode::HALF_UP);
+ return $avg->dividedBy($count, Pricedetail::PRICE_PRECISION, RoundingMode::HALF_UP);
}
/**
@@ -193,9 +192,9 @@ class PricedetailHelper
$val_base = $value;
//Convert value to base currency
- if (null !== $originCurrency) {
+ if ($originCurrency instanceof Currency) {
//Without an exchange rate we can not calculate the exchange rate
- if (null === $originCurrency->getExchangeRate() || $originCurrency->getExchangeRate()->isZero()) {
+ if (!$originCurrency->getExchangeRate() instanceof BigDecimal || $originCurrency->getExchangeRate()->isZero()) {
return null;
}
@@ -204,9 +203,9 @@ class PricedetailHelper
$val_target = $val_base;
//Convert value in base currency to target currency
- if (null !== $targetCurrency) {
+ if ($targetCurrency instanceof Currency) {
//Without an exchange rate we can not calculate the exchange rate
- if (null === $targetCurrency->getExchangeRate()) {
+ if (!$targetCurrency->getExchangeRate() instanceof BigDecimal) {
return null;
}
diff --git a/src/Services/ProjectSystem/ProjectBuildHelper.php b/src/Services/ProjectSystem/ProjectBuildHelper.php
index 8eee0772..269c7e4c 100644
--- a/src/Services/ProjectSystem/ProjectBuildHelper.php
+++ b/src/Services/ProjectSystem/ProjectBuildHelper.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Services\ProjectSystem;
+use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Helpers\Projects\ProjectBuildRequest;
use App\Services\Parts\PartLotWithdrawAddHelper;
+/**
+ * @see \App\Tests\Services\ProjectSystem\ProjectBuildHelperTest
+ */
class ProjectBuildHelper
{
- private PartLotWithdrawAddHelper $withdraw_add_helper;
-
- public function __construct(PartLotWithdrawAddHelper $withdraw_add_helper)
+ public function __construct(private readonly PartLotWithdrawAddHelper $withdraw_add_helper)
{
- $this->withdraw_add_helper = $withdraw_add_helper;
}
/**
* Returns the maximum buildable amount of the given BOM entry based on the stock of the used parts.
* This function only works for BOM entries that are associated with a part.
- * @param ProjectBOMEntry $projectBOMEntry
- * @return int
*/
public function getMaximumBuildableCountForBOMEntry(ProjectBOMEntry $projectBOMEntry): int
{
$part = $projectBOMEntry->getPart();
- if ($part === null) {
+ if (!$part instanceof Part) {
throw new \InvalidArgumentException('This function cannot determine the maximum buildable count for a BOM entry without a part!');
}
@@ -59,13 +60,11 @@ class ProjectBuildHelper
/**
* Returns the maximum buildable amount of the given project, based on the stock of the used parts in the BOM.
- * @param Project $project
- * @return int
*/
public function getMaximumBuildableCount(Project $project): int
{
$maximum_buildable_count = PHP_INT_MAX;
- foreach ($project->getBOMEntries() as $bom_entry) {
+ foreach ($project->getBomEntries() as $bom_entry) {
//Skip BOM entries without a part (as we can not determine that)
if (!$bom_entry->isPartBomEntry()) {
continue;
@@ -79,11 +78,9 @@ class ProjectBuildHelper
}
/**
- * Checks if the given project can be build with the current stock.
+ * Checks if the given project can be built with the current stock.
* This means that the maximum buildable count is greater or equal than the requested $number_of_projects
- * @param Project $project
- * @parm int $number_of_builds
- * @return bool
+ * @param int $number_of_builds
*/
public function isProjectBuildable(Project $project, int $number_of_builds = 1): bool
{
@@ -91,11 +88,8 @@ class ProjectBuildHelper
}
/**
- * Check if the given BOM entry can be build with the current stock.
+ * Check if the given BOM entry can be built with the current stock.
* This means that the maximum buildable count is greater or equal than the requested $number_of_projects
- * @param ProjectBOMEntry $bom_entry
- * @param int $number_of_builds
- * @return bool
*/
public function isBOMEntryBuildable(ProjectBOMEntry $bom_entry, int $number_of_builds = 1): bool
{
@@ -120,7 +114,7 @@ class ProjectBuildHelper
$part = $bomEntry->getPart();
//Skip BOM entries without a part (as we can not determine that)
- if ($part === null) {
+ if (!$part instanceof Part) {
continue;
}
@@ -137,9 +131,7 @@ class ProjectBuildHelper
/**
* Withdraw the parts from the stock using the given ProjectBuildRequest and create the build parts entries, if needed.
* The ProjectBuildRequest has to be validated before!!
- * You have to flush changes to DB afterwards
- * @param ProjectBuildRequest $buildRequest
- * @return void
+ * You have to flush changes to DB afterward
*/
public function doBuild(ProjectBuildRequest $buildRequest): void
{
@@ -159,4 +151,4 @@ class ProjectBuildHelper
$this->withdraw_add_helper->add($buildRequest->getBuildsPartLot(), $buildRequest->getNumberOfBuilds(), $message);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/ProjectSystem/ProjectBuildPartHelper.php b/src/Services/ProjectSystem/ProjectBuildPartHelper.php
index 136e2ff7..218f456e 100644
--- a/src/Services/ProjectSystem/ProjectBuildPartHelper.php
+++ b/src/Services/ProjectSystem/ProjectBuildPartHelper.php
@@ -1,17 +1,20 @@
.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\System;
+
+/**
+ * Helper service to retrieve the banner of this Part-DB installation
+ */
+class BannerHelper
+{
+ public function __construct(private readonly string $project_dir, private readonly string $partdb_banner)
+ {
+
+ }
+
+ /**
+ * Retrieves the banner from either the env variable or the banner.md file.
+ * @return string
+ */
+ public function getBanner(): string
+ {
+ $banner = $this->partdb_banner;
+ if ($banner === '') {
+ $banner_path = $this->project_dir
+ .DIRECTORY_SEPARATOR.'config'.DIRECTORY_SEPARATOR.'banner.md';
+
+ $tmp = file_get_contents($banner_path);
+ if (false === $tmp) {
+ throw new \RuntimeException('The banner file could not be read.');
+ }
+ $banner = $tmp;
+ }
+
+ return $banner;
+ }
+}
\ No newline at end of file
diff --git a/src/Services/System/UpdateAvailableManager.php b/src/Services/System/UpdateAvailableManager.php
new file mode 100644
index 00000000..31cb3266
--- /dev/null
+++ b/src/Services/System/UpdateAvailableManager.php
@@ -0,0 +1,143 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\System;
+
+use Psr\Log\LoggerInterface;
+use Shivas\VersioningBundle\Service\VersionManagerInterface;
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
+use Symfony\Contracts\Cache\CacheInterface;
+use Symfony\Contracts\Cache\ItemInterface;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Version\Version;
+
+/**
+ * This class checks if a new version of Part-DB is available.
+ */
+class UpdateAvailableManager
+{
+
+ private const API_URL = 'https://api.github.com/repos/Part-DB/Part-DB-server/releases/latest';
+ private const CACHE_KEY = 'uam_latest_version';
+ private const CACHE_TTL = 60 * 60 * 24 * 2; // 2 day
+
+ public function __construct(private readonly HttpClientInterface $httpClient,
+ private readonly CacheInterface $updateCache, private readonly VersionManagerInterface $versionManager,
+ private readonly bool $check_for_updates, private readonly LoggerInterface $logger,
+ #[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode)
+ {
+
+ }
+
+ /**
+ * Gets the latest version of Part-DB as string (e.g. "1.2.3").
+ * This value is cached for 2 days.
+ * @return string
+ */
+ public function getLatestVersionString(): string
+ {
+ return $this->getLatestVersionInfo()['version'];
+ }
+
+ /**
+ * Gets the latest version of Part-DB as Version object.
+ */
+ public function getLatestVersion(): Version
+ {
+ return Version::fromString($this->getLatestVersionString());
+ }
+
+ /**
+ * Gets the URL to the latest version of Part-DB on GitHub.
+ * @return string
+ */
+ public function getLatestVersionUrl(): string
+ {
+ return $this->getLatestVersionInfo()['url'];
+ }
+
+ /**
+ * Checks if a new version of Part-DB is available. This value is cached for 2 days.
+ * @return bool
+ */
+ public function isUpdateAvailable(): bool
+ {
+ //If we don't want to check for updates, we can return false
+ if (!$this->check_for_updates) {
+ return false;
+ }
+
+ $latestVersion = $this->getLatestVersion();
+ $currentVersion = $this->versionManager->getVersion();
+
+ return $latestVersion->isGreaterThan($currentVersion);
+ }
+
+ /**
+ * Get the latest version info. The value is cached for 2 days.
+ * @return array
+ * @phpstan-return array{version: string, url: string}
+ */
+ private function getLatestVersionInfo(): array
+ {
+ //If we don't want to check for updates, we can return dummy data
+ if (!$this->check_for_updates) {
+ return [
+ 'version' => '0.0.1',
+ 'url' => 'update-checking-disabled'
+ ];
+ }
+
+ return $this->updateCache->get(self::CACHE_KEY, function (ItemInterface $item) {
+ $item->expiresAfter(self::CACHE_TTL);
+ try {
+ $response = $this->httpClient->request('GET', self::API_URL);
+ $result = $response->toArray();
+ $tag_name = $result['tag_name'];
+
+ // Remove the leading 'v' from the tag name
+ $version = substr($tag_name, 1);
+
+ return [
+ 'version' => $version,
+ 'url' => $result['html_url'],
+ ];
+ } catch (\Exception $e) {
+ //When we are in dev mode, throw the exception, otherwise just silently log it
+ if ($this->is_dev_mode) {
+ throw $e;
+ }
+
+ //In the case of an error, try it again after half of the cache time
+ $item->expiresAfter(self::CACHE_TTL / 2);
+
+ $this->logger->error('Checking for updates failed: ' . $e->getMessage());
+
+ return [
+ 'version' => '0.0.1',
+ 'url' => 'update-checking-error'
+ ];
+ }
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/Services/Tools/ExchangeRateUpdater.php b/src/Services/Tools/ExchangeRateUpdater.php
index 241e2539..eac6de16 100644
--- a/src/Services/Tools/ExchangeRateUpdater.php
+++ b/src/Services/Tools/ExchangeRateUpdater.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Services\Tools;
use App\Entity\PriceInformations\Currency;
@@ -27,13 +29,8 @@ use Swap\Swap;
class ExchangeRateUpdater
{
- private string $base_currency;
- private Swap $swap;
-
- public function __construct(string $base_currency, Swap $swap)
+ public function __construct(private readonly string $base_currency, private readonly Swap $swap)
{
- $this->base_currency = $base_currency;
- $this->swap = $swap;
}
/**
diff --git a/src/Services/Tools/StatisticsHelper.php b/src/Services/Tools/StatisticsHelper.php
index 60ed568d..00bb05c9 100644
--- a/src/Services/Tools/StatisticsHelper.php
+++ b/src/Services/Tools/StatisticsHelper.php
@@ -49,7 +49,7 @@ use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
-use App\Entity\Parts\Storelocation;
+use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Repository\AttachmentRepository;
@@ -62,13 +62,11 @@ use InvalidArgumentException;
class StatisticsHelper
{
- protected EntityManagerInterface $em;
protected PartRepository $part_repo;
protected AttachmentRepository $attachment_repo;
- public function __construct(EntityManagerInterface $em)
+ public function __construct(protected EntityManagerInterface $em)
{
- $this->em = $em;
$this->part_repo = $this->em->getRepository(Part::class);
$this->attachment_repo = $this->em->getRepository(Attachment::class);
}
@@ -93,7 +91,7 @@ class StatisticsHelper
}
/**
- * Returns the number of all parts which have price informations.
+ * Returns the number of all parts which have price information.
*
* @throws NoResultException
* @throws NonUniqueResultException
@@ -115,7 +113,7 @@ class StatisticsHelper
'footprint' => Footprint::class,
'manufacturer' => Manufacturer::class,
'measurement_unit' => MeasurementUnit::class,
- 'storelocation' => Storelocation::class,
+ 'storelocation' => StorageLocation::class,
'supplier' => Supplier::class,
'currency' => Currency::class,
];
@@ -124,7 +122,6 @@ class StatisticsHelper
throw new InvalidArgumentException('No count for the given type available!');
}
- /** @var EntityRepository $repo */
$repo = $this->em->getRepository($arr[$type]);
return $repo->count([]);
@@ -147,7 +144,7 @@ class StatisticsHelper
}
/**
- * Gets the count of all external (only containing an URL) attachments.
+ * Gets the count of all external (only containing a URL) attachments.
*
* @throws NoResultException
* @throws NonUniqueResultException
@@ -158,7 +155,7 @@ class StatisticsHelper
}
/**
- * Gets the count of all attachments where the user uploaded an file.
+ * Gets the count of all attachments where the user uploaded a file.
*
* @throws NoResultException
* @throws NonUniqueResultException
diff --git a/src/Services/Tools/TagFinder.php b/src/Services/Tools/TagFinder.php
index aa0d02bd..80c89e0f 100644
--- a/src/Services/Tools/TagFinder.php
+++ b/src/Services/Tools/TagFinder.php
@@ -34,11 +34,8 @@ use function array_slice;
*/
class TagFinder
{
- protected EntityManagerInterface $em;
-
- public function __construct(EntityManagerInterface $entityManager)
+ public function __construct(protected EntityManagerInterface $em)
{
- $this->em = $entityManager;
}
/**
@@ -59,7 +56,7 @@ class TagFinder
$options = $resolver->resolve($options);
- //If the keyword is too short we will get to much results, which takes too much time...
+ //If the keyword is too short we will get too much results, which takes too much time...
if (mb_strlen($keyword) < $options['min_keyword_length']) {
return [];
}
@@ -69,7 +66,7 @@ class TagFinder
$qb->select('p.tags')
->from(Part::class, 'p')
- ->where('p.tags LIKE ?1')
+ ->where('ILIKE(p.tags, ?1) = TRUE')
->setMaxResults($options['query_limit'])
//->orderBy('RAND()')
->setParameter(1, '%'.$keyword.'%');
@@ -78,7 +75,7 @@ class TagFinder
//Iterate over each possible tags (which are comma separated) and extract tags which match our keyword
foreach ($possible_tags as $tags) {
- $tags = explode(',', $tags['tags']);
+ $tags = explode(',', (string) $tags['tags']);
$results = array_merge($results, preg_grep($keyword_regex, $tags));
}
diff --git a/src/Services/TranslationExtractor/PermissionExtractor.php b/src/Services/TranslationExtractor/PermissionExtractor.php
index 4994e054..e17cba7a 100644
--- a/src/Services/TranslationExtractor/PermissionExtractor.php
+++ b/src/Services/TranslationExtractor/PermissionExtractor.php
@@ -32,7 +32,7 @@ use Symfony\Component\Translation\MessageCatalogue;
*/
final class PermissionExtractor implements ExtractorInterface
{
- private array $permission_structure;
+ private readonly array $permission_structure;
private bool $finished = false;
public function __construct(PermissionManager $resolver)
@@ -81,7 +81,7 @@ final class PermissionExtractor implements ExtractorInterface
}
/**
- * Sets the prefix that should be used for new found messages.
+ * Sets the prefix that should be used for new-found messages.
*
* @param string $prefix The prefix
*/
diff --git a/src/Services/Trees/NodesListBuilder.php b/src/Services/Trees/NodesListBuilder.php
index ff8240e0..e65fa37e 100644
--- a/src/Services/Trees/NodesListBuilder.php
+++ b/src/Services/Trees/NodesListBuilder.php
@@ -22,64 +22,105 @@ declare(strict_types=1);
namespace App\Services\Trees;
+use App\Entity\Base\AbstractDBElement;
+use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
+use App\Repository\AttachmentContainingDBElementRepository;
+use App\Repository\DBElementRepository;
+use App\Repository\NamedDBElementRepository;
use App\Repository\StructuralDBElementRepository;
-use App\Services\UserSystem\UserCacheKeyGenerator;
+use App\Services\Cache\ElementCacheTagGenerator;
+use App\Services\Cache\UserCacheKeyGenerator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
/**
* This service gives you a flat list containing all structured entities in the order of the structure.
+ * @see \App\Tests\Services\Trees\NodesListBuilderTest
*/
class NodesListBuilder
{
- protected EntityManagerInterface $em;
- protected TagAwareCacheInterface $cache;
- protected UserCacheKeyGenerator $keyGenerator;
-
- public function __construct(EntityManagerInterface $em, TagAwareCacheInterface $treeCache, UserCacheKeyGenerator $keyGenerator)
- {
- $this->em = $em;
- $this->keyGenerator = $keyGenerator;
- $this->cache = $treeCache;
+ public function __construct(
+ protected EntityManagerInterface $em,
+ protected TagAwareCacheInterface $cache,
+ protected UserCacheKeyGenerator $keyGenerator,
+ protected ElementCacheTagGenerator $tagGenerator,
+ ) {
}
/**
- * Gets a flattened hierachical tree. Useful for generating option lists.
+ * Gets a flattened hierarchical tree. Useful for generating option lists.
* In difference to the Repository Function, the results here are cached.
*
- * @param string $class_name the class name of the entity you want to retrieve
- * @param AbstractStructuralDBElement|null $parent This entity will be used as root element. Set to null, to use global root
+ * @template T of AbstractNamedDBElement
*
- * @return AbstractStructuralDBElement[] a flattened list containing the tree elements
+ * @param string $class_name the class name of the entity you want to retrieve
+ * @phpstan-param class-string $class_name
+ * @param AbstractStructuralDBElement|null $parent This entity will be used as root element. Set to null, to use global root
+ *
+ * @return AbstractDBElement[] a flattened list containing the tree elements
+ * @phpstan-return list
*/
public function typeToNodesList(string $class_name, ?AbstractStructuralDBElement $parent = null): array
{
- $parent_id = null !== $parent ? $parent->getID() : '0';
+ /**
+ * We can not cache the entities directly, because loading them from cache will break the doctrine proxies.
+ */
+ //Retrieve the IDs of the elements
+ $ids = $this->getFlattenedIDs($class_name, $parent);
+
+ //Retrieve the elements from the IDs, the order is the same as in the $ids array
+ /** @var NamedDBElementRepository $repo */
+ $repo = $this->em->getRepository($class_name);
+
+ if ($repo instanceof AttachmentContainingDBElementRepository) {
+ return $repo->getElementsAndPreviewAttachmentByIDs($ids);
+ }
+
+ return $repo->findByIDInMatchingOrder($ids);
+ }
+
+ /**
+ * This functions returns the (cached) list of the IDs of the elements for the flattened tree.
+ * @template T of AbstractNamedDBElement
+ * @param string $class_name
+ * @phpstan-param class-string $class_name
+ * @param AbstractStructuralDBElement|null $parent
+ * @return int[]
+ */
+ private function getFlattenedIDs(string $class_name, ?AbstractStructuralDBElement $parent = null): array
+ {
+ $parent_id = $parent instanceof AbstractStructuralDBElement ? $parent->getID() : '0';
// Backslashes are not allowed in cache keys
- $secure_class_name = str_replace('\\', '_', $class_name);
+ $secure_class_name = $this->tagGenerator->getElementTypeCacheTag($class_name);
$key = 'list_'.$this->keyGenerator->generateKey().'_'.$secure_class_name.$parent_id;
return $this->cache->get($key, function (ItemInterface $item) use ($class_name, $parent, $secure_class_name) {
- // Invalidate when groups, a element with the class or the user changes
+ // Invalidate when groups, an element with the class or the user changes
$item->tag(['groups', 'tree_list', $this->keyGenerator->generateKey(), $secure_class_name]);
- /** @var StructuralDBElementRepository $repo */
+
+ /** @var NamedDBElementRepository $repo */
$repo = $this->em->getRepository($class_name);
- return $repo->toNodesList($parent);
+
+ return array_map(static fn(AbstractDBElement $element) => $element->getID(),
+ //@phpstan-ignore-next-line For some reason phpstan does not understand that $repo is a StructuralDBElementRepository
+ $repo->getFlatList($parent));
});
}
/**
- * Returns a flattened list of all (recursive) children elements of the given AbstractStructuralDBElement.
- * The value is cached for performance reasons.
+ * Returns a flattened list of all (recursive) children elements of the given AbstractStructuralDBElement.
+ * The value is cached for performance reasons.
*
* @template T of AbstractStructuralDBElement
- * @param T $element
- * @return T[]
+ * @param T $element
+ * @return AbstractStructuralDBElement[]
+ *
+ * @phpstan-return list
*/
public function getChildrenFlatList(AbstractStructuralDBElement $element): array
{
- return $this->typeToNodesList(get_class($element), $element);
+ return $this->typeToNodesList($element::class, $element);
}
}
diff --git a/src/Services/Trees/SidebarTreeUpdater.php b/src/Services/Trees/SidebarTreeUpdater.php
index 13c3fb6c..c0f93b1f 100644
--- a/src/Services/Trees/SidebarTreeUpdater.php
+++ b/src/Services/Trees/SidebarTreeUpdater.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Services\Trees;
-use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
final class SidebarTreeUpdater
{
private const CACHE_KEY = 'sidebar_tree_updated';
- private const TTL = 60 * 60 * 24; // 24 hours
+ private const TTL = 60 * 60 * 24;
- private CacheInterface $cache;
-
- public function __construct(TagAwareCacheInterface $treeCache)
+ public function __construct(
+ // 24 hours
+ private readonly TagAwareCacheInterface $cache
+ )
{
- $this->cache = $treeCache;
}
/**
* Returns the time when the sidebar tree was updated the last time.
* The frontend uses this information to reload the sidebar tree.
- * @return \DateTimeInterface
*/
public function getLastTreeUpdate(): \DateTimeInterface
{
@@ -49,7 +49,7 @@ final class SidebarTreeUpdater
//This tag and therfore this whole cache gets cleared by TreeCacheInvalidationListener when a structural element is changed
$item->tag('sidebar_tree_update');
- return new \DateTime();
+ return new \DateTimeImmutable();
});
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/Trees/StructuralElementRecursionHelper.php b/src/Services/Trees/StructuralElementRecursionHelper.php
index 4038798f..bc46d7f7 100644
--- a/src/Services/Trees/StructuralElementRecursionHelper.php
+++ b/src/Services/Trees/StructuralElementRecursionHelper.php
@@ -27,15 +27,12 @@ use Doctrine\ORM\EntityManagerInterface;
class StructuralElementRecursionHelper
{
- protected EntityManagerInterface $em;
-
- public function __construct(EntityManagerInterface $em)
+ public function __construct(protected EntityManagerInterface $em)
{
- $this->em = $em;
}
/**
- * Executes an function (callable) recursivly for $element and every of its children.
+ * Executes a function (callable) recursivly for $element and every of its children.
*
* @param AbstractStructuralDBElement $element The element on which the func should be executed
* @param callable $func The function which should be executed for each element.
diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php
index 38018c6e..18571306 100644
--- a/src/Services/Trees/ToolsTreeBuilder.php
+++ b/src/Services/Trees/ToolsTreeBuilder.php
@@ -23,55 +23,38 @@ declare(strict_types=1);
namespace App\Services\Trees;
use App\Entity\Attachments\AttachmentType;
-use App\Entity\Attachments\PartAttachment;
-use App\Entity\ProjectSystem\Project;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
-use App\Entity\Parts\Storelocation;
+use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
+use App\Entity\ProjectSystem\Project;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use App\Helpers\Trees\TreeViewNode;
-use App\Services\UserSystem\UserCacheKeyGenerator;
+use App\Services\Cache\UserCacheKeyGenerator;
+use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
-use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* This Service generates the tree structure for the tools.
- * Whenever you change something here, you has to clear the cache, because the results are cached for performance reasons.
+ * Whenever you change something here, you have to clear the cache, because the results are cached for performance reasons.
*/
class ToolsTreeBuilder
{
- protected TranslatorInterface $translator;
- protected UrlGeneratorInterface $urlGenerator;
- protected UserCacheKeyGenerator $keyGenerator;
- protected TagAwareCacheInterface $cache;
- protected Security $security;
-
- public function __construct(TranslatorInterface $translator, UrlGeneratorInterface $urlGenerator,
- TagAwareCacheInterface $treeCache, UserCacheKeyGenerator $keyGenerator,
- Security $security)
+ public function __construct(protected TranslatorInterface $translator, protected UrlGeneratorInterface $urlGenerator, protected TagAwareCacheInterface $cache, protected UserCacheKeyGenerator $keyGenerator, protected Security $security)
{
- $this->translator = $translator;
- $this->urlGenerator = $urlGenerator;
-
- $this->cache = $treeCache;
-
- $this->keyGenerator = $keyGenerator;
-
- $this->security = $security;
}
/**
- * Generates the tree for the tools menu.
+ * Generates the tree for the tools' menu.
* The result is cached.
*
* @return TreeViewNode[] the array containing all Nodes for the tools menu
@@ -85,20 +68,20 @@ class ToolsTreeBuilder
$item->tag(['tree_tools', 'groups', $this->keyGenerator->generateKey()]);
$tree = [];
- if (!empty($this->getToolsNode())) {
+ if ($this->getToolsNode() !== []) {
$tree[] = (new TreeViewNode($this->translator->trans('tree.tools.tools'), null, $this->getToolsNode()))
->setIcon('fa-fw fa-treeview fa-solid fa-toolbox');
}
- if (!empty($this->getEditNodes())) {
+ if ($this->getEditNodes() !== []) {
$tree[] = (new TreeViewNode($this->translator->trans('tree.tools.edit'), null, $this->getEditNodes()))
->setIcon('fa-fw fa-treeview fa-solid fa-pen-to-square');
}
- if (!empty($this->getShowNodes())) {
+ if ($this->getShowNodes() !== []) {
$tree[] = (new TreeViewNode($this->translator->trans('tree.tools.show'), null, $this->getShowNodes()))
->setIcon('fa-fw fa-treeview fa-solid fa-eye');
}
- if (!empty($this->getSystemNodes())) {
+ if ($this->getSystemNodes() !== []) {
$tree[] = (new TreeViewNode($this->translator->trans('tree.tools.system'), null, $this->getSystemNodes()))
->setIcon('fa-fw fa-treeview fa-solid fa-server');
}
@@ -143,6 +126,19 @@ class ToolsTreeBuilder
$this->urlGenerator->generate('tools_ic_logos')
))->setIcon('fa-treeview fa-fw fa-solid fa-flag');
}
+ if ($this->security->isGranted('@parts.import')) {
+ $nodes[] = (new TreeViewNode(
+ $this->translator->trans('parts.import.title'),
+ $this->urlGenerator->generate('parts_import')
+ ))->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;
}
@@ -186,7 +182,7 @@ class ToolsTreeBuilder
$this->urlGenerator->generate('manufacturer_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-industry');
}
- if ($this->security->isGranted('read', new Storelocation())) {
+ if ($this->security->isGranted('read', new StorageLocation())) {
$nodes[] = (new TreeViewNode(
$this->translator->trans('tree.tools.edit.storelocation'),
$this->urlGenerator->generate('store_location_new')
diff --git a/src/Services/Trees/TreeViewGenerator.php b/src/Services/Trees/TreeViewGenerator.php
index bc66ba47..23d6a406 100644
--- a/src/Services/Trees/TreeViewGenerator.php
+++ b/src/Services/Trees/TreeViewGenerator.php
@@ -25,65 +25,100 @@ namespace App\Services\Trees;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
-use App\Entity\ProjectSystem\Project;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
-use App\Entity\Parts\Storelocation;
+use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
+use App\Entity\ProjectSystem\Project;
use App\Helpers\Trees\TreeViewNode;
use App\Helpers\Trees\TreeViewNodeIterator;
-use App\Helpers\Trees\TreeViewNodeState;
+use App\Repository\NamedDBElementRepository;
use App\Repository\StructuralDBElementRepository;
+use App\Services\Cache\ElementCacheTagGenerator;
+use App\Services\Cache\UserCacheKeyGenerator;
use App\Services\EntityURLGenerator;
-use App\Services\Formatters\MarkdownParser;
-use App\Services\UserSystem\UserCacheKeyGenerator;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use RecursiveIteratorIterator;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function count;
+/**
+ * @see \App\Tests\Services\Trees\TreeViewGeneratorTest
+ */
class TreeViewGenerator
{
- protected $urlGenerator;
- protected $em;
- protected $cache;
- protected $keyGenerator;
- protected $translator;
+ public function __construct(
+ protected EntityURLGenerator $urlGenerator,
+ protected EntityManagerInterface $em,
+ protected TagAwareCacheInterface $cache,
+ protected ElementCacheTagGenerator $tagGenerator,
+ protected UserCacheKeyGenerator $keyGenerator,
+ protected TranslatorInterface $translator,
+ private readonly UrlGeneratorInterface $router,
+ protected bool $rootNodeExpandedByDefault,
+ protected bool $rootNodeEnabled,
- protected $rootNodeExpandedByDefault;
- protected $rootNodeEnabled;
+ ) {
+ }
- public function __construct(EntityURLGenerator $URLGenerator, EntityManagerInterface $em,
- TagAwareCacheInterface $treeCache, UserCacheKeyGenerator $keyGenerator, TranslatorInterface $translator, bool $rootNodeExpandedByDefault, bool $rootNodeEnabled)
+ /**
+ * Gets a TreeView list for the entities of the given class.
+ * The result is cached, if the full tree should be shown and no element should be selected.
+ *
+ * @param string $class The class for which the treeView should be generated
+ * @param AbstractStructuralDBElement|null $parent The root nodes in the tree should have this element as parent (use null, if you want to get all entities)
+ * @param string $mode The link type that will be generated for the hyperlink section of each node (see EntityURLGenerator for possible values).
+ * Set to empty string, to disable href field.
+ * @param AbstractDBElement|null $selectedElement The element that should be selected. If set to null, no element will be selected.
+ *
+ * @return TreeViewNode[] an array of TreeViewNode[] elements of the root elements
+ */
+ public function getTreeView(
+ string $class,
+ ?AbstractStructuralDBElement $parent = null,
+ string $mode = 'list_parts',
+ ?AbstractDBElement $selectedElement = null
+ ): array
{
- $this->urlGenerator = $URLGenerator;
- $this->em = $em;
- $this->cache = $treeCache;
- $this->keyGenerator = $keyGenerator;
- $this->translator = $translator;
+ //If we just want a part of a tree, don't cache it or select a specific element, don't cache it
+ if ($parent instanceof AbstractStructuralDBElement || $selectedElement instanceof AbstractDBElement) {
+ return $this->getTreeViewUncached($class, $parent, $mode, $selectedElement);
+ }
- $this->rootNodeExpandedByDefault = $rootNodeExpandedByDefault;
- $this->rootNodeEnabled = $rootNodeEnabled;
+ $secure_class_name = $this->tagGenerator->getElementTypeCacheTag($class);
+ $key = 'sidebar_treeview_'.$this->keyGenerator->generateKey().'_'.$secure_class_name;
+ $key .= $mode;
+
+ return $this->cache->get($key, function (ItemInterface $item) use ($class, $parent, $mode, $selectedElement, $secure_class_name) {
+ // Invalidate when groups, an element with the class or the user changes
+ $item->tag(['groups', 'tree_treeview', $this->keyGenerator->generateKey(), $secure_class_name]);
+ return $this->getTreeViewUncached($class, $parent, $mode, $selectedElement);
+ });
}
/**
* Gets a TreeView list for the entities of the given class.
*
- * @param string $class The class for which the treeView should be generated
- * @param AbstractStructuralDBElement|null $parent The root nodes in the tree should have this element as parent (use null, if you want to get all entities)
- * @param string $mode The link type that will be generated for the hyperlink section of each node (see EntityURLGenerator for possible values).
+ * @param string $class The class for which the treeView should be generated
+ * @param AbstractStructuralDBElement|null $parent The root nodes in the tree should have this element as parent (use null, if you want to get all entities)
+ * @param string $mode The link type that will be generated for the hyperlink section of each node (see EntityURLGenerator for possible values).
* Set to empty string, to disable href field.
- * @param AbstractDBElement|null $selectedElement The element that should be selected. If set to null, no element will be selected.
+ * @param AbstractDBElement|null $selectedElement The element that should be selected. If set to null, no element will be selected.
*
* @return TreeViewNode[] an array of TreeViewNode[] elements of the root elements
*/
- public function getTreeView(string $class, ?AbstractStructuralDBElement $parent = null, string $mode = 'list_parts', ?AbstractDBElement $selectedElement = null): array
- {
+ private function getTreeViewUncached(
+ string $class,
+ ?AbstractStructuralDBElement $parent = null,
+ string $mode = 'list_parts',
+ ?AbstractDBElement $selectedElement = null
+ ): array {
$head = [];
$href_type = $mode;
@@ -91,10 +126,11 @@ class TreeViewGenerator
//When we use the newEdit type, add the New Element node.
if ('newEdit' === $mode) {
//Generate the url for the new node
- $href = $this->urlGenerator->createURL(new $class());
+ //DO NOT try to create an object from the class, as this might be an proxy, which can not be easily initialized, so just pass the class_name directly
+ $href = $this->urlGenerator->createURL($class);
$new_node = new TreeViewNode($this->translator->trans('entity.tree.new'), $href);
//When the id of the selected element is null, then we have a new element, and we need to select "new" node
- if (null === $selectedElement || null === $selectedElement->getID()) {
+ if (!$selectedElement instanceof AbstractDBElement || null === $selectedElement->getID()) {
$new_node->setSelected(true);
}
$head[] = $new_node;
@@ -118,27 +154,30 @@ class TreeViewGenerator
$recursiveIterator = new RecursiveIteratorIterator($treeIterator, RecursiveIteratorIterator::SELF_FIRST);
foreach ($recursiveIterator as $item) {
/** @var TreeViewNode $item */
- if (null !== $selectedElement && $item->getId() === $selectedElement->getID()) {
+ if ($selectedElement instanceof AbstractDBElement && $item->getId() === $selectedElement->getID()) {
$item->setSelected(true);
}
- if (!empty($item->getNodes())) {
- $item->addTag((string) count($item->getNodes()));
+ if ($item->getNodes() !== null && $item->getNodes() !== []) {
+ $item->addTag((string)count($item->getNodes()));
}
- if (!empty($href_type) && null !== $item->getId()) {
- $entity = $this->em->getPartialReference($class, $item->getId());
+ if ($href_type !== '' && null !== $item->getId()) {
+ $entity = $this->em->find($class, $item->getId());
$item->setHref($this->urlGenerator->getURL($entity, $href_type));
}
//Translate text if text starts with $$
- if (0 === strpos($item->getText(), '$$')) {
+ if (str_starts_with($item->getText(), '$$')) {
$item->setText($this->translator->trans(substr($item->getText(), 2)));
}
}
if (($mode === 'list_parts_root' || $mode === 'devices') && $this->rootNodeEnabled) {
- $root_node = new TreeViewNode($this->entityClassToRootNodeString($class), null, $generic);
+ //We show the root node as a link to the list of all parts
+ $show_all_parts_url = $this->router->generate('parts_show_all');
+
+ $root_node = new TreeViewNode($this->entityClassToRootNodeString($class), $show_all_parts_url, $generic);
$root_node->setExpanded($this->rootNodeExpandedByDefault);
$root_node->setIcon($this->entityClassToRootNodeIcon($class));
@@ -150,43 +189,29 @@ class TreeViewGenerator
protected function entityClassToRootNodeString(string $class): string
{
- switch ($class) {
- case Category::class:
- return $this->translator->trans('category.labelp');
- case Storelocation::class:
- return $this->translator->trans('storelocation.labelp');
- case Footprint::class:
- return $this->translator->trans('footprint.labelp');
- case Manufacturer::class:
- return $this->translator->trans('manufacturer.labelp');
- case Supplier::class:
- return $this->translator->trans('supplier.labelp');
- case Project::class:
- return $this->translator->trans('project.labelp');
- default:
- return $this->translator->trans('tree.root_node.text');
- }
+ return match ($class) {
+ Category::class => $this->translator->trans('category.labelp'),
+ StorageLocation::class => $this->translator->trans('storelocation.labelp'),
+ Footprint::class => $this->translator->trans('footprint.labelp'),
+ Manufacturer::class => $this->translator->trans('manufacturer.labelp'),
+ Supplier::class => $this->translator->trans('supplier.labelp'),
+ Project::class => $this->translator->trans('project.labelp'),
+ default => $this->translator->trans('tree.root_node.text'),
+ };
}
protected function entityClassToRootNodeIcon(string $class): ?string
{
$icon = "fa-fw fa-treeview fa-solid ";
- switch ($class) {
- case Category::class:
- return $icon . 'fa-tags';
- case Storelocation::class:
- return $icon . 'fa-cube';
- case Footprint::class:
- return $icon . 'fa-microchip';
- case Manufacturer::class:
- return $icon . 'fa-industry';
- case Supplier::class:
- return $icon . 'fa-truck';
- case Project::class:
- return $icon . 'fa-archive';
- default:
- return null;
- }
+ return match ($class) {
+ Category::class => $icon.'fa-tags',
+ StorageLocation::class => $icon.'fa-cube',
+ Footprint::class => $icon.'fa-microchip',
+ Manufacturer::class => $icon.'fa-industry',
+ Supplier::class => $icon.'fa-truck',
+ Project::class => $icon.'fa-archive',
+ default => null,
+ };
}
/**
@@ -194,8 +219,9 @@ class TreeViewGenerator
* Gets a tree of TreeViewNode elements. The root elements has $parent as parent.
* The treeview is generic, that means the href are null and ID values are set.
*
- * @param string $class The class for which the tree should be generated
- * @param AbstractStructuralDBElement|null $parent the parent the root elements should have
+ * @param string $class The class for which the tree should be generated
+ * @phpstan-param class-string $class
+ * @param AbstractStructuralDBElement|null $parent the parent the root elements should have
*
* @return TreeViewNode[]
*/
@@ -204,26 +230,25 @@ class TreeViewGenerator
if (!is_a($class, AbstractNamedDBElement::class, true)) {
throw new InvalidArgumentException('$class must be a class string that implements StructuralDBElement or NamedDBElement!');
}
- if (null !== $parent && !is_a($parent, $class)) {
+ if ($parent instanceof AbstractStructuralDBElement && !$parent instanceof $class) {
throw new InvalidArgumentException('$parent must be of the type $class!');
}
- /** @var StructuralDBElementRepository $repo */
+ /** @var NamedDBElementRepository $repo */
$repo = $this->em->getRepository($class);
- //If we just want a part of a tree, dont cache it
- if (null !== $parent) {
- return $repo->getGenericNodeTree($parent);
+ //If we just want a part of a tree, don't cache it
+ if ($parent instanceof AbstractStructuralDBElement) {
+ return $repo->getGenericNodeTree($parent); //@phpstan-ignore-line PHPstan does not seem to recognize, that we have a StructuralDBElementRepository here, which have 1 argument
}
- $secure_class_name = str_replace('\\', '_', $class);
+ $secure_class_name = $this->tagGenerator->getElementTypeCacheTag($class);
$key = 'treeview_'.$this->keyGenerator->generateKey().'_'.$secure_class_name;
return $this->cache->get($key, function (ItemInterface $item) use ($repo, $parent, $secure_class_name) {
- // Invalidate when groups, a element with the class or the user changes
+ // Invalidate when groups, an element with the class or the user changes
$item->tag(['groups', 'tree_treeview', $this->keyGenerator->generateKey(), $secure_class_name]);
-
- return $repo->getGenericNodeTree($parent);
+ return $repo->getGenericNodeTree($parent); //@phpstan-ignore-line
});
}
}
diff --git a/src/Services/UserSystem/PasswordResetManager.php b/src/Services/UserSystem/PasswordResetManager.php
index 7b8a5be3..1a78cb60 100644
--- a/src/Services/UserSystem/PasswordResetManager.php
+++ b/src/Services/UserSystem/PasswordResetManager.php
@@ -35,21 +35,13 @@ use Symfony\Contracts\Translation\TranslatorInterface;
class PasswordResetManager
{
- protected MailerInterface $mailer;
- protected EntityManagerInterface $em;
protected PasswordHasherInterface $passwordEncoder;
- protected TranslatorInterface $translator;
- protected UserPasswordHasherInterface $userPasswordEncoder;
- public function __construct(MailerInterface $mailer, EntityManagerInterface $em,
- TranslatorInterface $translator, UserPasswordHasherInterface $userPasswordEncoder,
+ public function __construct(protected MailerInterface $mailer, protected EntityManagerInterface $em,
+ protected TranslatorInterface $translator, protected UserPasswordHasherInterface $userPasswordEncoder,
PasswordHasherFactoryInterface $encoderFactory)
{
- $this->em = $em;
- $this->mailer = $mailer;
$this->passwordEncoder = $encoderFactory->getPasswordHasher(User::class);
- $this->translator = $translator;
- $this->userPasswordEncoder = $userPasswordEncoder;
}
public function request(string $name_or_email): void
@@ -59,7 +51,7 @@ class PasswordResetManager
//Try to find a user by the given string
$user = $repo->findByEmailOrName($name_or_email);
//Do nothing if no user was found
- if (null === $user) {
+ if (!$user instanceof User) {
return;
}
@@ -67,11 +59,10 @@ class PasswordResetManager
$user->setPwResetToken($this->passwordEncoder->hash($unencrypted_token));
//Determine the expiration datetime of
- $expiration_date = new DateTime();
- $expiration_date->add(date_interval_create_from_date_string('1 day'));
+ $expiration_date = new \DateTimeImmutable("+1 day");
$user->setPwResetExpires($expiration_date);
- if (!empty($user->getEmail())) {
+ if ($user->getEmail() !== null && $user->getEmail() !== '') {
$address = new Address($user->getEmail(), $user->getFullName());
$mail = new TemplatedEmail();
$mail->to($address);
@@ -105,16 +96,15 @@ class PasswordResetManager
{
//Try to find the user
$repo = $this->em->getRepository(User::class);
- /** @var User|null $user */
- $user = $repo->findOneBy(['name' => $username]);
+ $user = $repo->findByUsername($username);
//If no user matching the name, show an error message
- if (null === $user) {
+ if (!$user instanceof User) {
return false;
}
//Check if token is expired yet
- if ($user->getPwResetExpires() < new DateTime()) {
+ if ($user->getPwResetExpires() < new \DateTimeImmutable()) {
return false;
}
@@ -128,7 +118,7 @@ class PasswordResetManager
//Remove token
$user->setPwResetToken(null);
- $user->setPwResetExpires(new DateTime());
+ $user->setPwResetExpires(new \DateTimeImmutable());
//Save to DB
$this->em->flush();
diff --git a/src/Services/UserSystem/PermissionManager.php b/src/Services/UserSystem/PermissionManager.php
index 717c0bac..663a7b67 100644
--- a/src/Services/UserSystem/PermissionManager.php
+++ b/src/Services/UserSystem/PermissionManager.php
@@ -36,23 +36,21 @@ use Symfony\Component\Yaml\Yaml;
* This class manages the permissions of users and groups.
* Permissions are defined in the config/permissions.yaml file, and are parsed and resolved by this class using the
* user and hierachical group PermissionData information.
+ * @see \App\Tests\Services\UserSystem\PermissionManagerTest
*/
class PermissionManager
{
- protected $permission_structure;
-
- protected bool $is_debug;
+ protected array $permission_structure;
protected string $cache_file;
/**
* PermissionResolver constructor.
*/
- public function __construct(bool $kernel_debug, string $kernel_cache_dir)
+ public function __construct(protected readonly bool $kernel_debug_enabled, string $kernel_cache_dir)
{
$cache_dir = $kernel_cache_dir;
//Here the cached structure will be saved.
$this->cache_file = $cache_dir.'/permissions.php.cache';
- $this->is_debug = $kernel_debug;
$this->permission_structure = $this->generatePermissionStructure();
}
@@ -113,8 +111,8 @@ class PermissionManager
/** @var Group $parent */
$parent = $user->getGroup();
- while (null !== $parent) { //The top group, has parent == null
- //Check if our current element gives a info about disallow/allow
+ while ($parent instanceof Group) { //The top group, has parent == null
+ //Check if our current element gives an info about disallow/allow
$allowed = $this->dontInherit($parent, $permission, $operation);
if (null !== $allowed) {
return $allowed;
@@ -123,7 +121,41 @@ class PermissionManager
$parent = $parent->getParent();
}
- return null; //The inherited value is never resolved. Should be treat as false, in Voters.
+ return null; //The inherited value is never resolved. Should be treated as false, in Voters.
+ }
+
+ /**
+ * Same as inherit(), but it checks if the access token has the required role.
+ * @param User $user the user for which the operation should be checked
+ * @param array $roles The roles associated with the authentication token
+ * @param string $permission the name of the permission for which should be checked
+ * @param string $operation the name of the operation for which should be checked
+ *
+ * @return bool|null true, if the user is allowed to do the operation (ALLOW), false if not (DISALLOW), and null,
+ * if the value is set to inherit
+ */
+ public function inheritWithAPILevel(User $user, array $roles, string $permission, string $operation): ?bool
+ {
+ //Check that the permission/operation combination is valid
+ if (! $this->isValidOperation($permission, $operation)) {
+ throw new InvalidArgumentException('The permission/operation combination "'.$permission.'/'.$operation.'" is not valid!');
+ }
+
+ //Get what API level we require for the permission/operation
+ $level_role = $this->permission_structure['perms'][$permission]['operations'][$operation]['apiTokenRole'];
+
+ //When no role was set (or it is null), then the operation is blocked for API access
+ if (null === $level_role) {
+ return false;
+ }
+
+ //Otherwise check if the token has the required role, if not, then the operation is blocked for API access
+ if (!in_array($level_role, $roles, true)) {
+ return false;
+ }
+
+ //If we have the required role, then we can check the permission
+ return $this->inherit($user, $permission, $operation);
}
/**
@@ -150,7 +182,7 @@ class PermissionManager
/**
* Lists the names of all operations that is supported for the given permission.
*
- * If the Permission is not existing at all, a exception is thrown.
+ * If the Permission is not existing at all, an exception is thrown.
*
* This function is useful for the support() function of the voters.
*
@@ -196,14 +228,15 @@ class PermissionManager
/**
* This functions sets all operations mentioned in the alsoSet value of a permission, so that the structure is always valid.
- * @param HasPermissionsInterface $user
- * @return void
+ * This function should be called after every setPermission() call.
+ * @return bool true if values were changed/corrected, false if not
*/
- public function ensureCorrectSetOperations(HasPermissionsInterface $user): void
+ public function ensureCorrectSetOperations(HasPermissionsInterface $user): bool
{
//If we have changed anything on the permission structure due to the alsoSet value, this becomes true, so we
//redo the whole process, to ensure that all alsoSet values are set recursively.
- $anything_changed = false;
+
+ $return_value = false;
do {
$anything_changed = false; //Reset the variable for the next iteration
@@ -216,31 +249,26 @@ class PermissionManager
//Set every op listed in also Set
foreach ($op['alsoSet'] as $set_also) {
//If the alsoSet value contains a dot then we set the operation of another permission
- if (false !== strpos($set_also, '.')) {
- [$set_perm, $set_op] = explode('.', $set_also);
- } else {
- //Else we set the operation of the same permission
- [$set_perm, $set_op] = [$perm_key, $set_also];
- }
+ [$set_perm, $set_op] = str_contains((string) $set_also, '.') ? explode('.', (string) $set_also) : [$perm_key, $set_also];
//Check if we change the value of the permission
if ($this->dontInherit($user, $set_perm, $set_op) !== true) {
$this->setPermission($user, $set_perm, $set_op, true);
//Mark the change, so we redo the whole process
$anything_changed = true;
+ $return_value = true;
}
}
}
}
}
} while($anything_changed);
+
+ return $return_value;
}
/**
* Sets all possible operations of all possible permissions of the given entity to the given value.
- * @param HasPermissionsInterface $perm_holder
- * @param bool|null $new_value
- * @return void
*/
public function setAllPermissions(HasPermissionsInterface $perm_holder, ?bool $new_value): void
{
@@ -254,11 +282,6 @@ class PermissionManager
/**
* Sets all operations of the given permissions to the given value.
* Please note that you have to call ensureCorrectSetOperations() after this function, to ensure that all alsoSet values are set.
- *
- * @param HasPermissionsInterface $perm_holder
- * @param string $permission
- * @param bool|null $new_value
- * @return void
*/
public function setAllOperationsOfPermission(HasPermissionsInterface $perm_holder, string $permission, ?bool $new_value): void
{
@@ -271,9 +294,47 @@ class PermissionManager
}
}
+ /**
+ * This function sets all operations of the given permission to the given value, except the ones listed in the except array.
+ */
+ public function setAllOperationsOfPermissionExcept(HasPermissionsInterface $perm_holder, string $permission, ?bool $new_value, array $except): void
+ {
+ if (!$this->isValidPermission($permission)) {
+ throw new InvalidArgumentException(sprintf('A permission with that name is not existing! Got %s.', $permission));
+ }
+
+ foreach ($this->permission_structure['perms'][$permission]['operations'] as $op_key => $op) {
+ if (in_array($op_key, $except, true)) {
+ continue;
+ }
+ $this->setPermission($perm_holder, $permission, $op_key, $new_value);
+ }
+ }
+
+ /**
+ * This function checks if the given user has any permission set to allow, either directly or inherited.
+ * @param User $user
+ * @return bool
+ */
+ public function hasAnyPermissionSetToAllowInherited(User $user): bool
+ {
+ //Iterate over all permissions
+ foreach ($this->permission_structure['perms'] as $perm_key => $permission) {
+ //Iterate over all operations of the permission
+ foreach ($permission['operations'] as $op_key => $op) {
+ //Check if the user has the permission set to allow
+ if ($this->inherit($user, $perm_key, $op_key) === true) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
protected function generatePermissionStructure()
{
- $cache = new ConfigCache($this->cache_file, $this->is_debug);
+ $cache = new ConfigCache($this->cache_file, $this->kernel_debug_enabled);
//Check if the cache is fresh, else regenerate it.
if (!$cache->isFresh()) {
diff --git a/src/Services/UserSystem/PermissionPresetsHelper.php b/src/Services/UserSystem/PermissionPresetsHelper.php
index 732e29f5..eeb80f61 100644
--- a/src/Services/UserSystem/PermissionPresetsHelper.php
+++ b/src/Services/UserSystem/PermissionPresetsHelper.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Services\UserSystem;
use App\Entity\UserSystem\PermissionData;
@@ -25,18 +27,15 @@ use App\Security\Interfaces\HasPermissionsInterface;
class PermissionPresetsHelper
{
- public const PRESET_ALL_INHERIT = 'all_inherit';
- public const PRESET_ALL_FORBID = 'all_forbid';
- public const PRESET_ALL_ALLOW = 'all_allow';
- public const PRESET_READ_ONLY = 'read_only';
- public const PRESET_EDITOR = 'editor';
- public const PRESET_ADMIN = 'admin';
+ final public const PRESET_ALL_INHERIT = 'all_inherit';
+ final public const PRESET_ALL_FORBID = 'all_forbid';
+ final public const PRESET_ALL_ALLOW = 'all_allow';
+ final public const PRESET_READ_ONLY = 'read_only';
+ final public const PRESET_EDITOR = 'editor';
+ final public const PRESET_ADMIN = 'admin';
- private PermissionManager $permissionResolver;
-
- public function __construct(PermissionManager $permissionResolver)
+ public function __construct(private readonly PermissionManager $permissionResolver)
{
- $this->permissionResolver = $permissionResolver;
}
/**
@@ -44,11 +43,10 @@ class PermissionPresetsHelper
* The permission data will be reset during the process and then the preset will be applied.
*
* @param string $preset_name The name of the preset to use
- * @return HasPermissionsInterface
*/
public function applyPreset(HasPermissionsInterface $perm_holder, string $preset_name): HasPermissionsInterface
{
- //We need to reset the permission data first (afterwards all values are inherit)
+ //We need to reset the permission data first (afterward all values are inherit)
$perm_holder->getPermissions()->resetPermissions();
switch($preset_name) {
@@ -93,6 +91,25 @@ class PermissionPresetsHelper
//Allow access to system log and server infos
$this->permissionResolver->setPermission($perm_holder, 'system', 'show_logs', PermissionData::ALLOW);
$this->permissionResolver->setPermission($perm_holder, 'system', 'server_infos', PermissionData::ALLOW);
+
+ //Allow import for all datastructures
+ $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'parts', PermissionData::ALLOW);
+ $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'parts_stock', PermissionData::ALLOW);
+ $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'categories', PermissionData::ALLOW);
+ $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'storelocations', PermissionData::ALLOW);
+ $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'footprints', PermissionData::ALLOW);
+ $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'manufacturers', PermissionData::ALLOW);
+ $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'attachment_types', PermissionData::ALLOW);
+ $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'currencies', PermissionData::ALLOW);
+ $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'measurement_units', PermissionData::ALLOW);
+ $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);
+ //Allow to show updates
+ $this->permissionResolver->setPermission($perm_holder, 'system', 'show_updates', PermissionData::ALLOW);
+
}
private function editor(HasPermissionsInterface $permHolder): HasPermissionsInterface
@@ -101,17 +118,18 @@ class PermissionPresetsHelper
$this->readOnly($permHolder);
//Set datastructures
- $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'parts', PermissionData::ALLOW);
+ //By default import is restricted to administrators, as it allows to fill up the database very fast
+ $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'parts', PermissionData::ALLOW, ['import']);
$this->permissionResolver->setAllOperationsOfPermission($permHolder, 'parts_stock', PermissionData::ALLOW);
- $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'categories', PermissionData::ALLOW);
- $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'storelocations', PermissionData::ALLOW);
- $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'footprints', PermissionData::ALLOW);
- $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'manufacturers', PermissionData::ALLOW);
- $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'attachment_types', PermissionData::ALLOW);
- $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'currencies', PermissionData::ALLOW);
- $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'measurement_units', PermissionData::ALLOW);
- $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'suppliers', PermissionData::ALLOW);
- $this->permissionResolver->setAllOperationsOfPermission($permHolder, 'projects', PermissionData::ALLOW);
+ $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'categories', PermissionData::ALLOW, ['import']);
+ $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'storelocations', PermissionData::ALLOW, ['import']);
+ $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'footprints', PermissionData::ALLOW, ['import']);
+ $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'manufacturers', PermissionData::ALLOW, ['import']);
+ $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'attachment_types', PermissionData::ALLOW, ['import']);
+ $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'currencies', PermissionData::ALLOW, ['import']);
+ $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'measurement_units', PermissionData::ALLOW, ['import']);
+ $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'suppliers', PermissionData::ALLOW, ['import']);
+ $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'projects', PermissionData::ALLOW, ['import']);
//Attachments permissions
$this->permissionResolver->setPermission($permHolder, 'attachments', 'show_private', PermissionData::ALLOW);
@@ -126,6 +144,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;
}
@@ -159,15 +180,15 @@ class PermissionPresetsHelper
return $perm_holder;
}
- private function AllForbid(HasPermissionsInterface $perm_holder): HasPermissionsInterface
+ private function allForbid(HasPermissionsInterface $perm_holder): HasPermissionsInterface
{
$this->permissionResolver->setAllPermissions($perm_holder, PermissionData::DISALLOW);
return $perm_holder;
}
- private function AllAllow(HasPermissionsInterface $perm_holder): HasPermissionsInterface
+ private function allAllow(HasPermissionsInterface $perm_holder): HasPermissionsInterface
{
$this->permissionResolver->setAllPermissions($perm_holder, PermissionData::ALLOW);
return $perm_holder;
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/UserSystem/PermissionSchemaUpdater.php b/src/Services/UserSystem/PermissionSchemaUpdater.php
index e8ebc6d0..104800dc 100644
--- a/src/Services/UserSystem/PermissionSchemaUpdater.php
+++ b/src/Services/UserSystem/PermissionSchemaUpdater.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Services\UserSystem;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\PermissionData;
use App\Entity\UserSystem\User;
+use App\Helpers\TrinaryLogicHelper;
use App\Security\Interfaces\HasPermissionsInterface;
+/**
+ * @see \App\Tests\Services\UserSystem\PermissionSchemaUpdaterTest
+ */
class PermissionSchemaUpdater
{
/**
* Check if the given user/group needs an update of its permission schema.
- * @param HasPermissionsInterface $holder
* @return bool True if the permission schema needs an update, false otherwise.
*/
public function isSchemaUpdateNeeded(HasPermissionsInterface $holder): bool
{
$perm_data = $holder->getPermissions();
- if ($perm_data->getSchemaVersion() < PermissionData::CURRENT_SCHEMA_VERSION) {
- return true;
- }
-
- return false;
+ return $perm_data->getSchemaVersion() < PermissionData::CURRENT_SCHEMA_VERSION;
}
/**
* Upgrades the permission schema of the given user/group to the chosen version.
* Please note that this function does not flush the changes to DB!
- * @param HasPermissionsInterface $holder
- * @param int $target_version
* @return bool True, if an upgrade was done, false if it was not needed.
*/
public function upgradeSchema(HasPermissionsInterface $holder, int $target_version = PermissionData::CURRENT_SCHEMA_VERSION): bool
{
+ $e = null;
if ($target_version > PermissionData::CURRENT_SCHEMA_VERSION) {
throw new \InvalidArgumentException('The target version is higher than the maximum possible schema version!');
}
@@ -66,11 +66,9 @@ class PermissionSchemaUpdater
$reflectionClass = new \ReflectionClass(self::class);
try {
$method = $reflectionClass->getMethod('upgradeSchemaToVersion'.($n + 1));
- //Set the method accessible, so we can call it (needed for PHP < 8.1)
- $method->setAccessible(true);
$method->invoke($this, $holder);
} catch (\ReflectionException $e) {
- throw new \RuntimeException('Could not find update method for schema version '.($n + 1));
+ throw new \RuntimeException('Could not find update method for schema version '.($n + 1), $e->getCode(), $e);
}
//Bump the schema version
@@ -84,8 +82,6 @@ class PermissionSchemaUpdater
/**
* Upgrades the permission schema of the given group and all of its parent groups to the chosen version.
* Please note that this function does not flush the changes to DB!
- * @param Group $group
- * @param int $target_version
* @return bool True if an upgrade was done, false if it was not needed.
*/
public function groupUpgradeSchemaRecursively(Group $group, int $target_version = PermissionData::CURRENT_SCHEMA_VERSION): bool
@@ -105,21 +101,19 @@ class PermissionSchemaUpdater
/**
* Upgrades the permissions schema of the given users and its parent (including parent groups) to the chosen version.
* Please note that this function does not flush the changes to DB!
- * @param User $user
- * @param int $target_version
* @return bool True if an upgrade was done, false if it was not needed.
*/
public function userUpgradeSchemaRecursively(User $user, int $target_version = PermissionData::CURRENT_SCHEMA_VERSION): bool
{
$updated = $this->upgradeSchema($user, $target_version);
- if ($user->getGroup()) {
+ if ($user->getGroup() instanceof Group) {
$updated = $this->groupUpgradeSchemaRecursively($user->getGroup(), $target_version) || $updated;
}
return $updated;
}
- private function upgradeSchemaToVersion1(HasPermissionsInterface $holder): void
+ private function upgradeSchemaToVersion1(HasPermissionsInterface $holder): void //@phpstan-ignore-line This is called via reflection
{
//Use the part edit permission to set the preset value for the new part stock permission
if (
@@ -136,7 +130,7 @@ class PermissionSchemaUpdater
}
}
- private function upgradeSchemaToVersion2(HasPermissionsInterface $holder): void
+ private function upgradeSchemaToVersion2(HasPermissionsInterface $holder): void //@phpstan-ignore-line This is called via reflection
{
//If the projects permissions are not defined yet, rename devices permission to projects (just copy its data over)
if (!$holder->getPermissions()->isAnyOperationOfPermissionSet('projects')) {
@@ -145,4 +139,22 @@ class PermissionSchemaUpdater
$holder->getPermissions()->removePermission('devices');
}
}
-}
\ No newline at end of file
+
+ private function upgradeSchemaToVersion3(HasPermissionsInterface $holder): void //@phpstan-ignore-line This is called via reflection
+ {
+ $permissions = $holder->getPermissions();
+
+ //If the system.show_updates permission is not defined yet, set it to true, if the user can view server info, server logs or edit users or groups
+ if (!$permissions->isPermissionSet('system', 'show_updates')) {
+
+ $new_value = TrinaryLogicHelper::or(
+ $permissions->getPermissionValue('system', 'server_infos'),
+ $permissions->getPermissionValue('system', 'show_logs'),
+ $permissions->getPermissionValue('users', 'edit'),
+ $permissions->getPermissionValue('groups', 'edit')
+ );
+
+ $permissions->setPermissionValue('system', 'show_updates', $new_value);
+ }
+ }
+}
diff --git a/src/Services/UserSystem/TFA/BackupCodeGenerator.php b/src/Services/UserSystem/TFA/BackupCodeGenerator.php
index bc47cab8..a13b9804 100644
--- a/src/Services/UserSystem/TFA/BackupCodeGenerator.php
+++ b/src/Services/UserSystem/TFA/BackupCodeGenerator.php
@@ -26,12 +26,12 @@ use Exception;
use RuntimeException;
/**
- * This class generates random backup codes for two factor authentication.
+ * This class generates random backup codes for two-factor authentication.
+ * @see \App\Tests\Services\UserSystem\TFA\BackupCodeGeneratorTest
*/
class BackupCodeGenerator
{
protected int $code_length;
- protected int $code_count;
/**
* BackupCodeGenerator constructor.
@@ -39,7 +39,7 @@ class BackupCodeGenerator
* @param int $code_length how many characters a single code should have
* @param int $code_count how many codes are generated for a whole backup set
*/
- public function __construct(int $code_length, int $code_count)
+ public function __construct(int $code_length, protected int $code_count)
{
if ($code_length > 32) {
throw new RuntimeException('Backup code can have maximum 32 digits!');
@@ -47,8 +47,6 @@ class BackupCodeGenerator
if ($code_length < 6) {
throw new RuntimeException('Code must have at least 6 digits to ensure security!');
}
-
- $this->code_count = $code_count;
$this->code_length = $code_length;
}
diff --git a/src/Services/UserSystem/TFA/BackupCodeManager.php b/src/Services/UserSystem/TFA/BackupCodeManager.php
index 9a422aa3..07484618 100644
--- a/src/Services/UserSystem/TFA/BackupCodeManager.php
+++ b/src/Services/UserSystem/TFA/BackupCodeManager.php
@@ -25,30 +25,28 @@ namespace App\Services\UserSystem\TFA;
use App\Entity\UserSystem\User;
/**
- * This services offers methods to manage backup codes for two factor authentication.
+ * This services offers methods to manage backup codes for two-factor authentication.
+ * @see \App\Tests\Services\UserSystem\TFA\BackupCodeManagerTest
*/
class BackupCodeManager
{
- protected BackupCodeGenerator $backupCodeGenerator;
-
- public function __construct(BackupCodeGenerator $backupCodeGenerator)
+ public function __construct(protected BackupCodeGenerator $backupCodeGenerator)
{
- $this->backupCodeGenerator = $backupCodeGenerator;
}
/**
* Enable backup codes for the given user, by generating a set of backup codes.
- * If the backup codes were already enabled before, they a.
+ * If the backup codes were already enabled before, nothing happens.
*/
public function enableBackupCodes(User $user): void
{
- if (empty($user->getBackupCodes())) {
+ if ($user->getBackupCodes() === []) {
$this->regenerateBackupCodes($user);
}
}
/**
- * Disable (remove) the backup codes when no other 2 factor authentication methods are enabled.
+ * Disable (remove) the backup codes when no other two-factor authentication methods are enabled.
*/
public function disableBackupCodesIfUnused(User $user): void
{
diff --git a/src/Services/UserSystem/TFA/DecoratedGoogleAuthenticator.php b/src/Services/UserSystem/TFA/DecoratedGoogleAuthenticator.php
new file mode 100644
index 00000000..05e5ed4c
--- /dev/null
+++ b/src/Services/UserSystem/TFA/DecoratedGoogleAuthenticator.php
@@ -0,0 +1,72 @@
+.
+ */
+namespace App\Services\UserSystem\TFA;
+
+use Scheb\TwoFactorBundle\Model\Google\TwoFactorInterface;
+use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Google\GoogleAuthenticatorInterface;
+use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
+use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+#[AsDecorator(GoogleAuthenticatorInterface::class)]
+class DecoratedGoogleAuthenticator implements GoogleAuthenticatorInterface
+{
+
+ public function __construct(
+ #[AutowireDecorated]
+ private readonly GoogleAuthenticatorInterface $inner,
+ private readonly RequestStack $requestStack)
+ {
+
+ }
+
+ public function checkCode(TwoFactorInterface $user, string $code): bool
+ {
+ return $this->inner->checkCode($user, $code);
+ }
+
+ public function getQRContent(TwoFactorInterface $user): string
+ {
+ $qr_content = $this->inner->getQRContent($user);
+
+ //Replace $$DOMAIN$$ with the current domain
+ $request = $this->requestStack->getCurrentRequest();
+
+ //If no request is available, just put "Part-DB" as domain
+ $domain = "Part-DB";
+
+ if ($request !== null) {
+ $domain = $request->getHttpHost();
+ }
+
+ //Domain must be url encoded
+ $domain = urlencode($domain);
+
+ return str_replace(urlencode('$$DOMAIN$$'), $domain, $qr_content);
+ }
+
+ public function generateSecret(): string
+ {
+ return $this->inner->generateSecret();
+ }
+}
diff --git a/src/Services/UserSystem/UserAvatarHelper.php b/src/Services/UserSystem/UserAvatarHelper.php
index 95b94dca..a694fa77 100644
--- a/src/Services/UserSystem/UserAvatarHelper.php
+++ b/src/Services/UserSystem/UserAvatarHelper.php
@@ -1,4 +1,7 @@
use_gravatar = $use_gravatar;
- $this->packages = $packages;
- $this->attachmentURLGenerator = $attachmentURLGenerator;
- $this->filterService = $filterService;
- $this->entityManager = $entityManager;
- $this->submitHandler = $attachmentSubmitHandler;
+ public function __construct(
+ private readonly bool $use_gravatar,
+ private readonly Packages $packages,
+ private readonly AttachmentURLGenerator $attachmentURLGenerator,
+ private readonly EntityManagerInterface $entityManager,
+ private readonly AttachmentSubmitHandler $submitHandler
+ ) {
}
/**
- * Returns the URL to the profile picture of the given user (in big size)
- * @param User $user
+ * Returns the URL to the profile picture of the given user (in big size)
+ *
* @return string
*/
public function getAvatarURL(User $user): string
{
//Check if the user has a master attachment defined (meaning he has explicitly defined a profile picture)
- if ($user->getMasterPictureAttachment() !== null) {
- return $this->attachmentURLGenerator->getThumbnailURL($user->getMasterPictureAttachment(), 'thumbnail_md');
+ if ($user->getMasterPictureAttachment() instanceof Attachment) {
+ return $this->attachmentURLGenerator->getThumbnailURL($user->getMasterPictureAttachment(), 'thumbnail_md')
+ ?? $this->packages->getUrl(self::IMG_DEFAULT_AVATAR_PATH);
}
//If not check if gravatar is enabled (then use gravatar URL)
@@ -70,14 +70,15 @@ class UserAvatarHelper
}
//Fallback to the default avatar picture
- return $this->packages->getUrl('/img/default_avatar.png');
+ return $this->packages->getUrl(self::IMG_DEFAULT_AVATAR_PATH);
}
public function getAvatarSmURL(User $user): string
{
//Check if the user has a master attachment defined (meaning he has explicitly defined a profile picture)
- if ($user->getMasterPictureAttachment() !== null) {
- return $this->attachmentURLGenerator->getThumbnailURL($user->getMasterPictureAttachment(), 'thumbnail_xs');
+ if ($user->getMasterPictureAttachment() instanceof Attachment) {
+ return $this->attachmentURLGenerator->getThumbnailURL($user->getMasterPictureAttachment(), 'thumbnail_xs')
+ ?? $this->packages->getUrl(self::IMG_DEFAULT_AVATAR_PATH);
}
//If not check if gravatar is enabled (then use gravatar URL)
@@ -85,20 +86,16 @@ class UserAvatarHelper
return $this->getGravatar($user, 50); //50px wide picture
}
- try {
- //Otherwise we can serve the relative path via Asset component
- return $this->filterService->getUrlOfFilteredImage('/img/default_avatar.png', 'thumbnail_xs');
- } catch (\Imagine\Exception\RuntimeException $e) {
- //If the filter fails, we can not serve the thumbnail and fall back to the original image and log an warning
- return $this->packages->getUrl('/img/default_avatar.png');
- }
+ //Otherwise serve the default image (its an SVG, so we dont need to thumbnail it)
+ return $this->packages->getUrl(self::IMG_DEFAULT_AVATAR_PATH);
}
public function getAvatarMdURL(User $user): string
{
//Check if the user has a master attachment defined (meaning he has explicitly defined a profile picture)
- if ($user->getMasterPictureAttachment() !== null) {
- return $this->attachmentURLGenerator->getThumbnailURL($user->getMasterPictureAttachment(), 'thumbnail_sm');
+ if ($user->getMasterPictureAttachment() instanceof Attachment) {
+ return $this->attachmentURLGenerator->getThumbnailURL($user->getMasterPictureAttachment(), 'thumbnail_sm')
+ ?? $this->packages->getUrl(self::IMG_DEFAULT_AVATAR_PATH);
}
//If not check if gravatar is enabled (then use gravatar URL)
@@ -106,20 +103,15 @@ class UserAvatarHelper
return $this->getGravatar($user, 150);
}
- try {
- //Otherwise we can serve the relative path via Asset component
- return $this->filterService->getUrlOfFilteredImage('/img/default_avatar.png', 'thumbnail_xs');
- } catch (\Imagine\Exception\RuntimeException $e) {
- //If the filter fails, we can not serve the thumbnail and fall back to the original image and log an warning
- return $this->packages->getUrl('/img/default_avatar.png');
- }
+ //Otherwise serve the default image (its an SVG, so we dont need to thumbnail it)
+ return $this->packages->getUrl(self::IMG_DEFAULT_AVATAR_PATH);
}
/**
* Get either a Gravatar URL or complete image tag for a specified email address.
*
- * @param User $user The user for which the gravator should be generated
+ * @param User $user The user for which the gravator should be generated
* @param int $s Size in pixels, defaults to 80px [ 1 - 2048 ]
* @param string $d Default imageset to use [ 404 | mm | identicon | monsterid | wavatar ]
* @param string $r Maximum rating (inclusive) [ g | pg | r | x ]
@@ -130,28 +122,24 @@ class UserAvatarHelper
private function getGravatar(User $user, int $s = 80, string $d = 'identicon', string $r = 'g'): string
{
$email = $user->getEmail();
- if (empty($email)) {
+ if ($email === null || $email === '') {
$email = 'Part-DB';
}
$url = 'https://www.gravatar.com/avatar/';
$url .= md5(strtolower(trim($email)));
- $url .= "?s=${s}&d=${d}&r=${r}";
- return $url;
+ return $url."?s=$s&d=$d&r=$r";
}
/**
* Handles the upload of the user avatar.
- * @param User $user
- * @param UploadedFile $file
- * @return Attachment
*/
public function handleAvatarUpload(User $user, UploadedFile $file): Attachment
{
//Determine which attachment to user
//If the user already has a master attachment, we use this one
- if ($user->getMasterPictureAttachment()) {
+ if ($user->getMasterPictureAttachment() instanceof Attachment) {
$attachment = $user->getMasterPictureAttachment();
} else { //Otherwise we have to create one
$attachment = new UserAttachment();
@@ -169,15 +157,14 @@ class UserAvatarHelper
}
$attachment->setAttachmentType($attachment_type);
- //$user->setMasterPictureAttachment($attachment);
}
//Handle the upload
- $this->submitHandler->handleFormSubmit($attachment, $file);
+ $this->submitHandler->handleUpload($attachment, new AttachmentUpload(file: $file));
//Set attachment as master picture
$user->setMasterPictureAttachment($attachment);
return $attachment;
}
-}
\ No newline at end of file
+}
diff --git a/src/Services/UserSystem/VoterHelper.php b/src/Services/UserSystem/VoterHelper.php
new file mode 100644
index 00000000..644351f4
--- /dev/null
+++ b/src/Services/UserSystem/VoterHelper.php
@@ -0,0 +1,127 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Services\UserSystem;
+
+use App\Entity\UserSystem\User;
+use App\Repository\UserRepository;
+use App\Security\ApiTokenAuthenticatedToken;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+
+/**
+ * @see \App\Tests\Services\UserSystem\VoterHelperTest
+ */
+final class VoterHelper
+{
+ private readonly UserRepository $userRepository;
+
+ public function __construct(private readonly PermissionManager $permissionManager, private readonly EntityManagerInterface $entityManager)
+ {
+ $this->userRepository = $this->entityManager->getRepository(User::class);
+ }
+
+ /**
+ * Checks if the operation on the given permission is granted for the given token.
+ * Similar to isGrantedTrinary, but returns false if the permission is not granted.
+ * @param TokenInterface $token The token to check
+ * @param string $permission The permission to check
+ * @param string $operation The operation to check
+ * @return bool
+ */
+ public function isGranted(TokenInterface $token, string $permission, string $operation): bool
+ {
+ return $this->isGrantedTrinary($token, $permission, $operation) ?? false;
+ }
+
+ /**
+ * Checks if the operation on the given permission is granted for the given token.
+ * The result is returned in trinary value, where null means inherted from the parent.
+ * @param TokenInterface $token The token to check
+ * @param string $permission The permission to check
+ * @param string $operation The operation to check
+ * @return bool|null The result of the check. Null means inherted from the parent.
+ */
+ public function isGrantedTrinary(TokenInterface $token, string $permission, string $operation): ?bool
+ {
+ $user = $token->getUser();
+
+ if ($user instanceof User) {
+ //A disallowed user is not allowed to do anything...
+ if ($user->isDisabled()) {
+ return false;
+ }
+ } else {
+ //Try to resolve the user from the token
+ $user = $this->resolveUser($token);
+ }
+
+ //If the token is a APITokenAuthenticated
+ if ($token instanceof ApiTokenAuthenticatedToken) {
+ //Use the special API token checker
+ return $this->permissionManager->inheritWithAPILevel($user, $token->getRoleNames(), $permission, $operation);
+ }
+
+ //Otherwise use the normal permission checker
+ return $this->permissionManager->inherit($user, $permission, $operation);
+ }
+
+ /**
+ * Resolves the user from the given token. If the token is anonymous, the anonymous user is returned.
+ * @return User
+ */
+ public function resolveUser(TokenInterface $token): User
+ {
+ $user = $token->getUser();
+ //If the user is a User entity, just return it
+ if ($user instanceof User) {
+ return $user;
+ }
+
+ //If the user is null, return the anonymous user
+ if ($user === null) {
+ $user = $this->userRepository->getAnonymousUser();
+ if (!$user instanceof User) {
+ throw new \RuntimeException('The anonymous user could not be resolved.');
+ }
+ return $user;
+ }
+
+ //Otherwise throw an exception
+ throw new \RuntimeException('The user could not be resolved.');
+ }
+
+ /**
+ * Checks if the permission operation combination with the given names is existing.
+ * Just a proxy to the permission manager.
+ *
+ * @param string $permission the name of the permission which should be checked
+ * @param string $operation the name of the operation which should be checked
+ *
+ * @return bool true if the given permission operation combination is existing
+ */
+ public function isValidOperation(string $permission, string $operation): bool
+ {
+ return $this->permissionManager->isValidOperation($permission, $operation);
+ }
+}
\ No newline at end of file
diff --git a/src/State/CurrentApiTokenProvider.php b/src/State/CurrentApiTokenProvider.php
new file mode 100644
index 00000000..f989d504
--- /dev/null
+++ b/src/State/CurrentApiTokenProvider.php
@@ -0,0 +1,49 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\State;
+
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\State\ProviderInterface;
+use App\Security\ApiTokenAuthenticatedToken;
+use Symfony\Bundle\SecurityBundle\Security;
+
+
+class CurrentApiTokenProvider implements ProviderInterface
+{
+
+ public function __construct(private readonly Security $security)
+ {
+
+ }
+
+ public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
+ {
+ $securityToken = $this->security->getToken();
+ if (!$securityToken instanceof ApiTokenAuthenticatedToken) {
+ return null;
+ }
+
+ return $securityToken->getApiToken();
+ }
+}
\ No newline at end of file
diff --git a/src/State/PartDBInfoProvider.php b/src/State/PartDBInfoProvider.php
new file mode 100644
index 00000000..c6760ede
--- /dev/null
+++ b/src/State/PartDBInfoProvider.php
@@ -0,0 +1,44 @@
+versionManager->getVersion()->toString(),
+ git_branch: $this->gitVersionInfo->getGitBranchName(),
+ git_commit: $this->gitVersionInfo->getGitCommitHash(),
+ title: $this->partdb_title,
+ banner: $this->bannerHelper->getBanner(),
+ default_uri: $this->default_uri,
+ global_timezone: $this->global_timezone,
+ base_currency: $this->base_currency,
+ global_locale: $this->global_locale,
+ );
+ }
+}
diff --git a/src/Translation/Fixes/SegmentAwareXliffFileDumper.php b/src/Translation/Fixes/SegmentAwareXliffFileDumper.php
new file mode 100644
index 00000000..4b44ef01
--- /dev/null
+++ b/src/Translation/Fixes/SegmentAwareXliffFileDumper.php
@@ -0,0 +1,242 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Translation\Fixes;
+
+use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
+use Symfony\Component\Translation\Dumper\FileDumper;
+use Symfony\Component\Translation\MessageCatalogue;
+use Symfony\Component\Translation\Exception\InvalidArgumentException;
+
+/**
+ * Backport of the XliffFile dumper from Symfony 7.2, which supports segment attributes and notes, this keeps the
+ * metadata when editing the translations from inside Symfony.
+ */
+#[AsDecorator("translation.dumper.xliff")]
+class SegmentAwareXliffFileDumper extends FileDumper
+{
+
+ public function __construct(
+ private string $extension = 'xlf',
+ ) {
+ }
+
+ public function formatCatalogue(MessageCatalogue $messages, string $domain, array $options = []): string
+ {
+ $xliffVersion = '1.2';
+ if (\array_key_exists('xliff_version', $options)) {
+ $xliffVersion = $options['xliff_version'];
+ }
+
+ if (\array_key_exists('default_locale', $options)) {
+ $defaultLocale = $options['default_locale'];
+ } else {
+ $defaultLocale = \Locale::getDefault();
+ }
+
+ if ('1.2' === $xliffVersion) {
+ return $this->dumpXliff1($defaultLocale, $messages, $domain, $options);
+ }
+ if ('2.0' === $xliffVersion) {
+ return $this->dumpXliff2($defaultLocale, $messages, $domain);
+ }
+
+ throw new InvalidArgumentException(\sprintf('No support implemented for dumping XLIFF version "%s".', $xliffVersion));
+ }
+
+ protected function getExtension(): string
+ {
+ return $this->extension;
+ }
+
+ private function dumpXliff1(string $defaultLocale, MessageCatalogue $messages, ?string $domain, array $options = []): string
+ {
+ $toolInfo = ['tool-id' => 'symfony', 'tool-name' => 'Symfony'];
+ if (\array_key_exists('tool_info', $options)) {
+ $toolInfo = array_merge($toolInfo, $options['tool_info']);
+ }
+
+ $dom = new \DOMDocument('1.0', 'utf-8');
+ $dom->formatOutput = true;
+
+ $xliff = $dom->appendChild($dom->createElement('xliff'));
+ $xliff->setAttribute('version', '1.2');
+ $xliff->setAttribute('xmlns', 'urn:oasis:names:tc:xliff:document:1.2');
+
+ $xliffFile = $xliff->appendChild($dom->createElement('file'));
+ $xliffFile->setAttribute('source-language', str_replace('_', '-', $defaultLocale));
+ $xliffFile->setAttribute('target-language', str_replace('_', '-', $messages->getLocale()));
+ $xliffFile->setAttribute('datatype', 'plaintext');
+ $xliffFile->setAttribute('original', 'file.ext');
+
+ $xliffHead = $xliffFile->appendChild($dom->createElement('header'));
+ $xliffTool = $xliffHead->appendChild($dom->createElement('tool'));
+ foreach ($toolInfo as $id => $value) {
+ $xliffTool->setAttribute($id, $value);
+ }
+
+ if ($catalogueMetadata = $messages->getCatalogueMetadata('', $domain) ?? []) {
+ $xliffPropGroup = $xliffHead->appendChild($dom->createElement('prop-group'));
+ foreach ($catalogueMetadata as $key => $value) {
+ $xliffProp = $xliffPropGroup->appendChild($dom->createElement('prop'));
+ $xliffProp->setAttribute('prop-type', $key);
+ $xliffProp->appendChild($dom->createTextNode($value));
+ }
+ }
+
+ $xliffBody = $xliffFile->appendChild($dom->createElement('body'));
+ foreach ($messages->all($domain) as $source => $target) {
+ $translation = $dom->createElement('trans-unit');
+
+ $translation->setAttribute('id', strtr(substr(base64_encode(hash('xxh128', $source, true)), 0, 7), '/+', '._'));
+ $translation->setAttribute('resname', $source);
+
+ $s = $translation->appendChild($dom->createElement('source'));
+ $s->appendChild($dom->createTextNode($source));
+
+ // Does the target contain characters requiring a CDATA section?
+ $text = 1 === preg_match('/[&<>]/', $target) ? $dom->createCDATASection($target) : $dom->createTextNode($target);
+
+ $targetElement = $dom->createElement('target');
+ $metadata = $messages->getMetadata($source, $domain);
+ if ($this->hasMetadataArrayInfo('target-attributes', $metadata)) {
+ foreach ($metadata['target-attributes'] as $name => $value) {
+ $targetElement->setAttribute($name, $value);
+ }
+ }
+ $t = $translation->appendChild($targetElement);
+ $t->appendChild($text);
+
+ if ($this->hasMetadataArrayInfo('notes', $metadata)) {
+ foreach ($metadata['notes'] as $note) {
+ if (!isset($note['content'])) {
+ continue;
+ }
+
+ $n = $translation->appendChild($dom->createElement('note'));
+ $n->appendChild($dom->createTextNode($note['content']));
+
+ if (isset($note['priority'])) {
+ $n->setAttribute('priority', $note['priority']);
+ }
+
+ if (isset($note['from'])) {
+ $n->setAttribute('from', $note['from']);
+ }
+ }
+ }
+
+ $xliffBody->appendChild($translation);
+ }
+
+ return $dom->saveXML();
+ }
+
+ private function dumpXliff2(string $defaultLocale, MessageCatalogue $messages, ?string $domain): string
+ {
+ $dom = new \DOMDocument('1.0', 'utf-8');
+ $dom->formatOutput = true;
+
+ $xliff = $dom->appendChild($dom->createElement('xliff'));
+ $xliff->setAttribute('xmlns', 'urn:oasis:names:tc:xliff:document:2.0');
+ $xliff->setAttribute('version', '2.0');
+ $xliff->setAttribute('srcLang', str_replace('_', '-', $defaultLocale));
+ $xliff->setAttribute('trgLang', str_replace('_', '-', $messages->getLocale()));
+
+ $xliffFile = $xliff->appendChild($dom->createElement('file'));
+ if (str_ends_with($domain, MessageCatalogue::INTL_DOMAIN_SUFFIX)) {
+ $xliffFile->setAttribute('id', substr($domain, 0, -\strlen(MessageCatalogue::INTL_DOMAIN_SUFFIX)).'.'.$messages->getLocale());
+ } else {
+ $xliffFile->setAttribute('id', $domain.'.'.$messages->getLocale());
+ }
+
+ if ($catalogueMetadata = $messages->getCatalogueMetadata('', $domain) ?? []) {
+ $xliff->setAttribute('xmlns:m', 'urn:oasis:names:tc:xliff:metadata:2.0');
+ $xliffMetadata = $xliffFile->appendChild($dom->createElement('m:metadata'));
+ foreach ($catalogueMetadata as $key => $value) {
+ $xliffMeta = $xliffMetadata->appendChild($dom->createElement('prop'));
+ $xliffMeta->setAttribute('type', $key);
+ $xliffMeta->appendChild($dom->createTextNode($value));
+ }
+ }
+
+ foreach ($messages->all($domain) as $source => $target) {
+ $translation = $dom->createElement('unit');
+ $translation->setAttribute('id', strtr(substr(base64_encode(hash('xxh128', $source, true)), 0, 7), '/+', '._'));
+
+ if (\strlen($source) <= 80) {
+ $translation->setAttribute('name', $source);
+ }
+
+ $metadata = $messages->getMetadata($source, $domain);
+
+ // Add notes section
+ if ($this->hasMetadataArrayInfo('notes', $metadata)) {
+ $notesElement = $dom->createElement('notes');
+ foreach ($metadata['notes'] as $note) {
+ $n = $dom->createElement('note');
+ $n->appendChild($dom->createTextNode($note['content'] ?? ''));
+ unset($note['content']);
+
+ foreach ($note as $name => $value) {
+ $n->setAttribute($name, $value);
+ }
+ $notesElement->appendChild($n);
+ }
+ $translation->appendChild($notesElement);
+ }
+
+ $segment = $translation->appendChild($dom->createElement('segment'));
+
+ if ($this->hasMetadataArrayInfo('segment-attributes', $metadata)) {
+ foreach ($metadata['segment-attributes'] as $name => $value) {
+ $segment->setAttribute($name, $value);
+ }
+ }
+
+ $s = $segment->appendChild($dom->createElement('source'));
+ $s->appendChild($dom->createTextNode($source));
+
+ // Does the target contain characters requiring a CDATA section?
+ $text = 1 === preg_match('/[&<>]/', $target) ? $dom->createCDATASection($target) : $dom->createTextNode($target);
+
+ $targetElement = $dom->createElement('target');
+ if ($this->hasMetadataArrayInfo('target-attributes', $metadata)) {
+ foreach ($metadata['target-attributes'] as $name => $value) {
+ $targetElement->setAttribute($name, $value);
+ }
+ }
+ $t = $segment->appendChild($targetElement);
+ $t->appendChild($text);
+
+ $xliffFile->appendChild($translation);
+ }
+
+ return $dom->saveXML();
+ }
+
+ private function hasMetadataArrayInfo(string $key, ?array $metadata = null): bool
+ {
+ return is_iterable($metadata[$key] ?? null);
+ }
+}
\ No newline at end of file
diff --git a/src/Translation/Fixes/SegmentAwareXliffFileLoader.php b/src/Translation/Fixes/SegmentAwareXliffFileLoader.php
new file mode 100644
index 00000000..12455e87
--- /dev/null
+++ b/src/Translation/Fixes/SegmentAwareXliffFileLoader.php
@@ -0,0 +1,262 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Translation\Fixes;
+
+use Symfony\Component\Config\Resource\FileResource;
+use Symfony\Component\Config\Util\Exception\InvalidXmlException;
+use Symfony\Component\Config\Util\Exception\XmlParsingException;
+use Symfony\Component\Config\Util\XmlUtils;
+use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
+use Symfony\Component\Translation\Exception\InvalidResourceException;
+use Symfony\Component\Translation\Exception\NotFoundResourceException;
+use Symfony\Component\Translation\Exception\RuntimeException;
+use Symfony\Component\Translation\Loader\LoaderInterface;
+use Symfony\Component\Translation\MessageCatalogue;
+use Symfony\Component\Translation\Util\XliffUtils;
+
+/**
+ * Backport of the XliffFile dumper from Symfony 7.2, which supports segment attributes and notes, this keeps the
+ * metadata when editing the translations from inside Symfony.
+ */
+#[AsDecorator("translation.loader.xliff")]
+class SegmentAwareXliffFileLoader implements LoaderInterface
+{
+ public function load(mixed $resource, string $locale, string $domain = 'messages'): MessageCatalogue
+ {
+ if (!class_exists(XmlUtils::class)) {
+ throw new RuntimeException('Loading translations from the Xliff format requires the Symfony Config component.');
+ }
+
+ if (!$this->isXmlString($resource)) {
+ if (!stream_is_local($resource)) {
+ throw new InvalidResourceException(\sprintf('This is not a local file "%s".', $resource));
+ }
+
+ if (!file_exists($resource)) {
+ throw new NotFoundResourceException(\sprintf('File "%s" not found.', $resource));
+ }
+
+ if (!is_file($resource)) {
+ throw new InvalidResourceException(\sprintf('This is neither a file nor an XLIFF string "%s".', $resource));
+ }
+ }
+
+ try {
+ if ($this->isXmlString($resource)) {
+ $dom = XmlUtils::parse($resource);
+ } else {
+ $dom = XmlUtils::loadFile($resource);
+ }
+ } catch (\InvalidArgumentException|XmlParsingException|InvalidXmlException $e) {
+ throw new InvalidResourceException(\sprintf('Unable to load "%s": ', $resource).$e->getMessage(), $e->getCode(), $e);
+ }
+
+ if ($errors = XliffUtils::validateSchema($dom)) {
+ throw new InvalidResourceException(\sprintf('Invalid resource provided: "%s"; Errors: ', $resource).XliffUtils::getErrorsAsString($errors));
+ }
+
+ $catalogue = new MessageCatalogue($locale);
+ $this->extract($dom, $catalogue, $domain);
+
+ if (is_file($resource) && class_exists(FileResource::class)) {
+ $catalogue->addResource(new FileResource($resource));
+ }
+
+ return $catalogue;
+ }
+
+ private function extract(\DOMDocument $dom, MessageCatalogue $catalogue, string $domain): void
+ {
+ $xliffVersion = XliffUtils::getVersionNumber($dom);
+
+ if ('1.2' === $xliffVersion) {
+ $this->extractXliff1($dom, $catalogue, $domain);
+ }
+
+ if ('2.0' === $xliffVersion) {
+ $this->extractXliff2($dom, $catalogue, $domain);
+ }
+ }
+
+ /**
+ * Extract messages and metadata from DOMDocument into a MessageCatalogue.
+ */
+ private function extractXliff1(\DOMDocument $dom, MessageCatalogue $catalogue, string $domain): void
+ {
+ $xml = simplexml_import_dom($dom);
+ $encoding = $dom->encoding ? strtoupper($dom->encoding) : null;
+
+ $namespace = 'urn:oasis:names:tc:xliff:document:1.2';
+ $xml->registerXPathNamespace('xliff', $namespace);
+
+ foreach ($xml->xpath('//xliff:file') as $file) {
+ $fileAttributes = $file->attributes();
+
+ $file->registerXPathNamespace('xliff', $namespace);
+
+ foreach ($file->xpath('.//xliff:prop') as $prop) {
+ $catalogue->setCatalogueMetadata($prop->attributes()['prop-type'], (string) $prop, $domain);
+ }
+
+ foreach ($file->xpath('.//xliff:trans-unit') as $translation) {
+ $attributes = $translation->attributes();
+
+ if (!(isset($attributes['resname']) || isset($translation->source))) {
+ continue;
+ }
+
+ $source = (string) (isset($attributes['resname']) && $attributes['resname'] ? $attributes['resname'] : $translation->source);
+
+ if (isset($translation->target)
+ && 'needs-translation' === (string) $translation->target->attributes()['state']
+ && \in_array((string) $translation->target, [$source, (string) $translation->source], true)
+ ) {
+ continue;
+ }
+
+ // If the xlf file has another encoding specified, try to convert it because
+ // simple_xml will always return utf-8 encoded values
+ $target = $this->utf8ToCharset((string) ($translation->target ?? $translation->source), $encoding);
+
+ $catalogue->set($source, $target, $domain);
+
+ $metadata = [
+ 'source' => (string) $translation->source,
+ 'file' => [
+ 'original' => (string) $fileAttributes['original'],
+ ],
+ ];
+ if ($notes = $this->parseNotesMetadata($translation->note, $encoding)) {
+ $metadata['notes'] = $notes;
+ }
+
+ if (isset($translation->target) && $translation->target->attributes()) {
+ $metadata['target-attributes'] = [];
+ foreach ($translation->target->attributes() as $key => $value) {
+ $metadata['target-attributes'][$key] = (string) $value;
+ }
+ }
+
+ if (isset($attributes['id'])) {
+ $metadata['id'] = (string) $attributes['id'];
+ }
+
+ $catalogue->setMetadata($source, $metadata, $domain);
+ }
+ }
+ }
+
+ private function extractXliff2(\DOMDocument $dom, MessageCatalogue $catalogue, string $domain): void
+ {
+ $xml = simplexml_import_dom($dom);
+ $encoding = $dom->encoding ? strtoupper($dom->encoding) : null;
+
+ $xml->registerXPathNamespace('xliff', 'urn:oasis:names:tc:xliff:document:2.0');
+
+ foreach ($xml->xpath('//xliff:unit') as $unit) {
+ foreach ($unit->segment as $segment) {
+ $attributes = $unit->attributes();
+ $source = $attributes['name'] ?? $segment->source;
+
+ // If the xlf file has another encoding specified, try to convert it because
+ // simple_xml will always return utf-8 encoded values
+ $target = $this->utf8ToCharset((string) ($segment->target ?? $segment->source), $encoding);
+
+ $catalogue->set((string) $source, $target, $domain);
+
+ $metadata = [];
+ if ($segment->attributes()) {
+ $metadata['segment-attributes'] = [];
+ foreach ($segment->attributes() as $key => $value) {
+ $metadata['segment-attributes'][$key] = (string) $value;
+ }
+ }
+
+ if (isset($segment->target) && $segment->target->attributes()) {
+ $metadata['target-attributes'] = [];
+ foreach ($segment->target->attributes() as $key => $value) {
+ $metadata['target-attributes'][$key] = (string) $value;
+ }
+ }
+
+ if (isset($unit->notes)) {
+ $metadata['notes'] = [];
+ foreach ($unit->notes->note as $noteNode) {
+ $note = [];
+ foreach ($noteNode->attributes() as $key => $value) {
+ $note[$key] = (string) $value;
+ }
+ $note['content'] = (string) $noteNode;
+ $metadata['notes'][] = $note;
+ }
+ }
+
+ $catalogue->setMetadata((string) $source, $metadata, $domain);
+ }
+ }
+ }
+
+ /**
+ * Convert a UTF8 string to the specified encoding.
+ */
+ private function utf8ToCharset(string $content, ?string $encoding = null): string
+ {
+ if ('UTF-8' !== $encoding && $encoding) {
+ return mb_convert_encoding($content, $encoding, 'UTF-8');
+ }
+
+ return $content;
+ }
+
+ private function parseNotesMetadata(?\SimpleXMLElement $noteElement = null, ?string $encoding = null): array
+ {
+ $notes = [];
+
+ if (null === $noteElement) {
+ return $notes;
+ }
+
+ /** @var \SimpleXMLElement $xmlNote */
+ foreach ($noteElement as $xmlNote) {
+ $noteAttributes = $xmlNote->attributes();
+ $note = ['content' => $this->utf8ToCharset((string) $xmlNote, $encoding)];
+ if (isset($noteAttributes['priority'])) {
+ $note['priority'] = (int) $noteAttributes['priority'];
+ }
+
+ if (isset($noteAttributes['from'])) {
+ $note['from'] = (string) $noteAttributes['from'];
+ }
+
+ $notes[] = $note;
+ }
+
+ return $notes;
+ }
+
+ private function isXmlString(string $resource): bool
+ {
+ return str_starts_with($resource, '.
*/
-
namespace App\Twig;
+use App\Entity\Attachments\Attachment;
use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Misc\FAIconGenerator;
use Twig\Extension\AbstractExtension;
@@ -27,22 +30,17 @@ use Twig\TwigFunction;
final class AttachmentExtension extends AbstractExtension
{
- protected AttachmentURLGenerator $attachmentURLGenerator;
- protected FAIconGenerator $FAIconGenerator;
-
- public function __construct(AttachmentURLGenerator $attachmentURLGenerator, FAIconGenerator $FAIconGenerator)
+ public function __construct(protected AttachmentURLGenerator $attachmentURLGenerator, protected FAIconGenerator $FAIconGenerator)
{
- $this->attachmentURLGenerator = $attachmentURLGenerator;
- $this->FAIconGenerator = $FAIconGenerator;
}
public function getFunctions(): array
{
return [
/* Returns the URL to a thumbnail of the given attachment */
- new TwigFunction('attachment_thumbnail', [$this->attachmentURLGenerator, 'getThumbnailURL']),
- /* Returns the font awesome icon class which is representing the given file extension */
- new TwigFunction('ext_to_fa_icon', [$this->FAIconGenerator, 'fileExtensionToFAType']),
+ new TwigFunction('attachment_thumbnail', fn(Attachment $attachment, string $filter_name = 'thumbnail_sm'): ?string => $this->attachmentURLGenerator->getThumbnailURL($attachment, $filter_name)),
+ /* Returns the font awesome icon class which is representing the given file extension (We allow null here for attachments without extension) */
+ new TwigFunction('ext_to_fa_icon', fn(?string $extension): string => $this->FAIconGenerator->fileExtensionToFAType($extension ?? '')),
];
}
-}
\ No newline at end of file
+}
diff --git a/src/Twig/BarcodeExtension.php b/src/Twig/BarcodeExtension.php
index 051995f6..ae1973e3 100644
--- a/src/Twig/BarcodeExtension.php
+++ b/src/Twig/BarcodeExtension.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Twig;
use Com\Tecnick\Barcode\Barcode;
use Twig\Extension\AbstractExtension;
-use Twig\TwigFilter;
use Twig\TwigFunction;
final class BarcodeExtension extends AbstractExtension
@@ -31,7 +32,7 @@ final class BarcodeExtension extends AbstractExtension
{
return [
/* Generates a barcode with the given Type and Data and returns it as an SVG represenation */
- new TwigFunction('barcode_svg', [$this, 'barcodeSVG']),
+ new TwigFunction('barcode_svg', fn(string $content, string $type = 'QRCODE'): string => $this->barcodeSVG($content, $type)),
];
}
diff --git a/src/Twig/EntityExtension.php b/src/Twig/EntityExtension.php
index 6d477d88..762ebb09 100644
--- a/src/Twig/EntityExtension.php
+++ b/src/Twig/EntityExtension.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Twig;
use App\Entity\Attachments\Attachment;
@@ -29,11 +31,12 @@ use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
-use App\Entity\Parts\Storelocation;
+use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
+use App\Exceptions\EntityNotSupportedException;
use App\Services\ElementTypeNameGenerator;
use App\Services\EntityURLGenerator;
use App\Services\Trees\TreeViewGenerator;
@@ -41,26 +44,20 @@ use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
use Twig\TwigTest;
+/**
+ * @see \App\Tests\Twig\EntityExtensionTest
+ */
final class EntityExtension extends AbstractExtension
{
- protected EntityURLGenerator $entityURLGenerator;
- protected TreeViewGenerator $treeBuilder;
- private ElementTypeNameGenerator $nameGenerator;
-
- public function __construct(EntityURLGenerator $entityURLGenerator, TreeViewGenerator $treeBuilder, ElementTypeNameGenerator $elementTypeNameGenerator)
+ public function __construct(protected EntityURLGenerator $entityURLGenerator, protected TreeViewGenerator $treeBuilder, private readonly ElementTypeNameGenerator $nameGenerator)
{
- $this->entityURLGenerator = $entityURLGenerator;
- $this->treeBuilder = $treeBuilder;
- $this->nameGenerator = $elementTypeNameGenerator;
}
public function getTests(): array
{
return [
/* Checks if the given variable is an entitity (instance of AbstractDBElement) */
- new TwigTest('entity', static function ($var) {
- return $var instanceof AbstractDBElement;
- }),
+ new TwigTest('entity', static fn($var) => $var instanceof AbstractDBElement),
];
}
@@ -68,20 +65,31 @@ final class EntityExtension extends AbstractExtension
{
return [
/* Returns a string representation of the given entity */
- new TwigFunction('entity_type', [$this, 'getEntityType']),
+ new TwigFunction('entity_type', fn(object $entity): ?string => $this->getEntityType($entity)),
/* Returns the URL to the given entity */
- new TwigFunction('entity_url', [$this, 'generateEntityURL']),
+ new TwigFunction('entity_url', fn(AbstractDBElement $entity, string $method = 'info'): string => $this->generateEntityURL($entity, $method)),
+ /* Returns the URL to the given entity in timetravel mode */
+ new TwigFunction('timetravel_url', fn(AbstractDBElement $element, \DateTimeInterface $dateTime): ?string => $this->timeTravelURL($element, $dateTime)),
/* Generates a JSON array of the given tree */
- new TwigFunction('tree_data', [$this, 'treeData']),
+ new TwigFunction('tree_data', fn(AbstractDBElement $element, string $type = 'newEdit'): string => $this->treeData($element, $type)),
/* Gets a human readable label for the type of the given entity */
- new TwigFunction('entity_type_label', [$this->nameGenerator, 'getLocalizedTypeLabel']),
+ new TwigFunction('entity_type_label', fn(object|string $entity): string => $this->nameGenerator->getLocalizedTypeLabel($entity)),
];
}
+ public function timeTravelURL(AbstractDBElement $element, \DateTimeInterface $dateTime): ?string
+ {
+ try {
+ return $this->entityURLGenerator->timeTravelURL($element, $dateTime);
+ } catch (EntityNotSupportedException) {
+ return null;
+ }
+ }
+
public function treeData(AbstractDBElement $element, string $type = 'newEdit'): string
{
- $tree = $this->treeBuilder->getTreeView(get_class($element), null, $type, $element);
+ $tree = $this->treeBuilder->getTreeView($element::class, null, $type, $element);
return json_encode($tree, JSON_THROW_ON_ERROR);
}
@@ -96,7 +104,7 @@ final class EntityExtension extends AbstractExtension
$map = [
Part::class => 'part',
Footprint::class => 'footprint',
- Storelocation::class => 'storelocation',
+ StorageLocation::class => 'storelocation',
Manufacturer::class => 'manufacturer',
Category::class => 'category',
Project::class => 'device',
@@ -115,6 +123,6 @@ final class EntityExtension extends AbstractExtension
}
}
- return false;
+ return null;
}
-}
\ No newline at end of file
+}
diff --git a/src/Twig/FormatExtension.php b/src/Twig/FormatExtension.php
index 87c906b1..76628ccd 100644
--- a/src/Twig/FormatExtension.php
+++ b/src/Twig/FormatExtension.php
@@ -22,72 +22,38 @@ declare(strict_types=1);
namespace App\Twig;
-use App\Entity\Attachments\Attachment;
-use App\Entity\Base\AbstractDBElement;
-use App\Entity\ProjectSystem\Project;
-use App\Entity\LabelSystem\LabelProfile;
-use App\Entity\Parts\Category;
-use App\Entity\Parts\Footprint;
-use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
-use App\Entity\Parts\Part;
-use App\Entity\Parts\Storelocation;
-use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
-use App\Entity\UserSystem\Group;
-use App\Entity\UserSystem\User;
use App\Services\Formatters\AmountFormatter;
-use App\Services\Attachments\AttachmentURLGenerator;
-use App\Services\EntityURLGenerator;
-use App\Services\Misc\FAIconGenerator;
use App\Services\Formatters\MarkdownParser;
use App\Services\Formatters\MoneyFormatter;
use App\Services\Formatters\SIFormatter;
-use App\Services\Trees\TreeViewGenerator;
use Brick\Math\BigDecimal;
-use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
-use Symfony\Component\Serializer\SerializerInterface;
-use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
-use Twig\TwigFunction;
-use Twig\TwigTest;
-
-use function get_class;
final class FormatExtension extends AbstractExtension
{
- protected MarkdownParser $markdownParser;
- protected MoneyFormatter $moneyFormatter;
- protected SIFormatter $siformatter;
- protected AmountFormatter $amountFormatter;
-
-
- public function __construct(MarkdownParser $markdownParser, MoneyFormatter $moneyFormatter,
- SIFormatter $SIFormatter, AmountFormatter $amountFormatter)
+ public function __construct(protected MarkdownParser $markdownParser, protected MoneyFormatter $moneyFormatter, protected SIFormatter $siformatter, protected AmountFormatter $amountFormatter)
{
- $this->markdownParser = $markdownParser;
- $this->moneyFormatter = $moneyFormatter;
- $this->siformatter = $SIFormatter;
- $this->amountFormatter = $amountFormatter;
}
public function getFilters(): array
{
return [
/* Mark the given text as markdown, which will be rendered in the browser */
- new TwigFilter('format_markdown', [$this->markdownParser, 'markForRendering'], [
+ new TwigFilter('format_markdown', fn(string $markdown, bool $inline_mode = false): string => $this->markdownParser->markForRendering($markdown, $inline_mode), [
'pre_escape' => 'html',
'is_safe' => ['html'],
]),
/* Format the given amount as money, using a given currency */
- new TwigFilter('format_money', [$this, 'formatCurrency']),
+ new TwigFilter('format_money', fn($amount, ?Currency $currency = null, int $decimals = 5): string => $this->formatCurrency($amount, $currency, $decimals)),
/* Format the given number using SI prefixes and the given unit (string) */
- new TwigFilter('format_si', [$this, 'siFormat']),
+ new TwigFilter('format_si', fn($value, $unit, $decimals = 2, bool $show_all_digits = false): string => $this->siFormat($value, $unit, $decimals, $show_all_digits)),
/** Format the given amount using the given MeasurementUnit */
- new TwigFilter('format_amount', [$this, 'amountFormat']),
- /** Format the given number of bytes as human readable number */
- new TwigFilter('format_bytes', [$this, 'formatBytes']),
+ new TwigFilter('format_amount', fn($value, ?MeasurementUnit $unit, array $options = []): string => $this->amountFormat($value, $unit, $options)),
+ /** Format the given number of bytes as human-readable number */
+ new TwigFilter('format_bytes', fn(int $bytes, int $precision = 2): string => $this->formatBytes($bytes, $precision)),
];
}
@@ -112,13 +78,12 @@ final class FormatExtension extends AbstractExtension
/**
* @param $bytes
- * @param int $precision
- * @return string
*/
public function formatBytes(int $bytes, int $precision = 2): string
{
$size = ['B','kB','MB','GB','TB','PB','EB','ZB','YB'];
$factor = floor((strlen((string) $bytes) - 1) / 3);
- return sprintf("%.{$precision}f", $bytes / pow(1024, $factor)) . ' ' . @$size[$factor];
+ //We use the real (10 based) SI prefix here
+ return sprintf("%.{$precision}f", $bytes / (1000 ** $factor)) . ' ' . @$size[$factor];
}
}
diff --git a/src/Twig/InfoProviderExtension.php b/src/Twig/InfoProviderExtension.php
new file mode 100644
index 00000000..a963b778
--- /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) {
+ 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) {
+ return null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Twig/LogExtension.php b/src/Twig/LogExtension.php
new file mode 100644
index 00000000..34dad988
--- /dev/null
+++ b/src/Twig/LogExtension.php
@@ -0,0 +1,45 @@
+.
+ */
+namespace App\Twig;
+
+use App\Entity\LogSystem\AbstractLogEntry;
+use App\Services\LogSystem\LogDataFormatter;
+use App\Services\LogSystem\LogDiffFormatter;
+use Twig\Extension\AbstractExtension;
+use Twig\TwigFunction;
+
+final class LogExtension extends AbstractExtension
+{
+
+ public function __construct(private readonly LogDataFormatter $logDataFormatter, private readonly LogDiffFormatter $logDiffFormatter)
+ {
+ }
+
+ public function getFunctions(): array
+ {
+ return [
+ new TwigFunction('format_log_data', fn($data, AbstractLogEntry $logEntry, string $fieldName): string => $this->logDataFormatter->formatData($data, $logEntry, $fieldName), ['is_safe' => ['html']]),
+ new TwigFunction('format_log_diff', fn($old_data, $new_data): string => $this->logDiffFormatter->formatDiff($old_data, $new_data), ['is_safe' => ['html']]),
+ ];
+ }
+}
diff --git a/src/Twig/MiscExtension.php b/src/Twig/MiscExtension.php
new file mode 100644
index 00000000..93762d35
--- /dev/null
+++ b/src/Twig/MiscExtension.php
@@ -0,0 +1,60 @@
+.
+ */
+namespace App\Twig;
+
+use Symfony\Component\HttpFoundation\Request;
+use Twig\TwigFunction;
+use App\Services\LogSystem\EventCommentNeededHelper;
+use Twig\Extension\AbstractExtension;
+
+final class MiscExtension extends AbstractExtension
+{
+ public function __construct(private readonly EventCommentNeededHelper $eventCommentNeededHelper)
+ {
+ }
+
+ public function getFunctions(): array
+ {
+ return [
+ new TwigFunction('event_comment_needed',
+ fn(string $operation_type) => $this->eventCommentNeededHelper->isCommentNeeded($operation_type)
+ ),
+
+ new TwigFunction('uri_without_host', $this->uri_without_host(...))
+ ];
+ }
+
+ /**
+ * Similar to the getUri function of the request, but does not contain protocol and host.
+ * @param Request $request
+ * @return string
+ */
+ public function uri_without_host(Request $request): string
+ {
+ if (null !== $qs = $request->getQueryString()) {
+ $qs = '?'.$qs;
+ }
+
+ return $request->getBaseUrl().$request->getPathInfo().$qs;
+ }
+}
diff --git a/src/Twig/Sandbox/InheritanceSecurityPolicy.php b/src/Twig/Sandbox/InheritanceSecurityPolicy.php
index 052366c0..93e874e9 100644
--- a/src/Twig/Sandbox/InheritanceSecurityPolicy.php
+++ b/src/Twig/Sandbox/InheritanceSecurityPolicy.php
@@ -22,7 +22,6 @@ use Twig\Sandbox\SecurityNotAllowedTagError;
use Twig\Sandbox\SecurityPolicyInterface;
use Twig\Template;
-use function get_class;
use function in_array;
use function is_array;
@@ -35,19 +34,11 @@ use function is_array;
*/
final class InheritanceSecurityPolicy implements SecurityPolicyInterface
{
- private array $allowedTags;
- private array $allowedFilters;
private array $allowedMethods;
- private array $allowedProperties;
- private array $allowedFunctions;
- public function __construct(array $allowedTags = [], array $allowedFilters = [], array $allowedMethods = [], array $allowedProperties = [], array $allowedFunctions = [])
+ public function __construct(private array $allowedTags = [], private array $allowedFilters = [], array $allowedMethods = [], private array $allowedProperties = [], private array $allowedFunctions = [])
{
- $this->allowedTags = $allowedTags;
- $this->allowedFilters = $allowedFilters;
$this->setAllowedMethods($allowedMethods);
- $this->allowedProperties = $allowedProperties;
- $this->allowedFunctions = $allowedFunctions;
}
public function setAllowedTags(array $tags): void
@@ -65,7 +56,7 @@ final class InheritanceSecurityPolicy implements SecurityPolicyInterface
$this->allowedMethods = [];
foreach ($methods as $class => $m) {
$this->allowedMethods[$class] = array_map(
- static function ($value) { return strtr($value, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); }, is_array($m) ? $m : [$m]);
+ static fn($value): string => strtr($value, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), is_array($m) ? $m : [$m]);
}
}
@@ -112,7 +103,7 @@ final class InheritanceSecurityPolicy implements SecurityPolicyInterface
if ($obj instanceof $class) {
$allowed = in_array($method, $methods, true);
- //CHANGED: Only break if we the method is allowed, otherwise try it on the other methods
+ //CHANGED: Only break if the method is allowed, otherwise try it on the other methods
if ($allowed) {
break;
}
@@ -120,7 +111,7 @@ final class InheritanceSecurityPolicy implements SecurityPolicyInterface
}
if (!$allowed) {
- $class = get_class($obj);
+ $class = $obj::class;
throw new SecurityNotAllowedMethodError(sprintf('Calling "%s" method on a "%s" object is not allowed.', $method, $class), $class, $method);
}
@@ -133,7 +124,7 @@ final class InheritanceSecurityPolicy implements SecurityPolicyInterface
if ($obj instanceof $class) {
$allowed = in_array($property, is_array($properties) ? $properties : [$properties], true);
- //CHANGED: Only break if we the method is allowed, otherwise try it on the other methods
+ //CHANGED: Only break if the method is allowed, otherwise try it on the other methods
if ($allowed) {
break;
}
@@ -141,7 +132,7 @@ final class InheritanceSecurityPolicy implements SecurityPolicyInterface
}
if (!$allowed) {
- $class = get_class($obj);
+ $class = $obj::class;
throw new SecurityNotAllowedPropertyError(sprintf('Calling "%s" property on a "%s" object is not allowed.', $property, $class), $class, $property);
}
diff --git a/src/Twig/Sandbox/SandboxedLabelExtension.php b/src/Twig/Sandbox/SandboxedLabelExtension.php
new file mode 100644
index 00000000..59fb0af0
--- /dev/null
+++ b/src/Twig/Sandbox/SandboxedLabelExtension.php
@@ -0,0 +1,51 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Twig\Sandbox;
+
+use App\Services\LabelSystem\LabelTextReplacer;
+use Twig\Extension\AbstractExtension;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+
+class SandboxedLabelExtension extends AbstractExtension
+{
+ public function __construct(private readonly LabelTextReplacer $labelTextReplacer)
+ {
+
+ }
+
+ public function getFunctions(): array
+ {
+ return [
+ new TwigFunction('placeholder', fn(string $text, object $label_target) => $this->labelTextReplacer->handlePlaceholderOrReturnNull($text, $label_target)),
+ ];
+ }
+
+ public function getFilters(): array
+ {
+ return [
+ new TwigFilter('placeholders', fn(string $text, object $label_target) => $this->labelTextReplacer->replace($text, $label_target)),
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/Twig/TwigCoreExtension.php b/src/Twig/TwigCoreExtension.php
index aecbdd17..352e09d3 100644
--- a/src/Twig/TwigCoreExtension.php
+++ b/src/Twig/TwigCoreExtension.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Twig;
-use App\Entity\Base\AbstractDBElement;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
+use Twig\TwigFunction;
use Twig\TwigTest;
/**
* The functionalities here extend the Twig with some core functions, which are independently of Part-DB.
+ * @see \App\Tests\Twig\TwigCoreExtensionTest
*/
final class TwigCoreExtension extends AbstractExtension
{
- protected ObjectNormalizer $objectNormalizer;
-
- public function __construct(ObjectNormalizer $objectNormalizer)
+ public function __construct(protected ObjectNormalizer $objectNormalizer)
{
- $this->objectNormalizer = $objectNormalizer;
+ }
+
+ public function getFunctions(): array
+ {
+ return [
+ /* Returns the enum cases as values */
+ new TwigFunction('enum_cases', $this->getEnumCases(...)),
+ ];
}
public function getTests(): array
@@ -44,30 +52,37 @@ final class TwigCoreExtension extends AbstractExtension
/*
* Checks if a given variable is an instance of a given class. E.g. ` x is instanceof('App\Entity\Parts\Part')`
*/
- new TwigTest('instanceof', static function ($var, $instance) {
- return $var instanceof $instance;
- }),
+ 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 function ($var) {
- return is_object($var);
- }),
+ new TwigTest('object', static fn($var): bool => is_object($var)),
+ new TwigTest('enum', fn($var) => $var instanceof \UnitEnum),
];
}
- public function getFilters()
+ /**
+ * @param string $enum_class
+ * @phpstan-param class-string $enum_class
+ */
+ public function getEnumCases(string $enum_class): array
+ {
+ if (!enum_exists($enum_class)) {
+ throw new \InvalidArgumentException(sprintf('The given class "%s" is not an enum!', $enum_class));
+ }
+
+ /** @noinspection PhpUndefinedMethodInspection */
+ return ($enum_class)::cases();
+ }
+
+ public function getFilters(): array
{
return [
/* Converts the given object to an array representation of the public/accessible properties */
- new TwigFilter('to_array', [$this, 'toArray']),
+ new TwigFilter('to_array', fn($object) => $this->toArray($object)),
];
}
- public function toArray($object)
+ public function toArray(object|array $object): array
{
- if(! is_object($object) && ! is_array($object)) {
- throw new \InvalidArgumentException('The given variable is not an object or array!');
- }
-
//If it is already an array, we can just return it
if(is_array($object)) {
return $object;
@@ -75,4 +90,4 @@ final class TwigCoreExtension extends AbstractExtension
return $this->objectNormalizer->normalize($object, null);
}
-}
\ No newline at end of file
+}
diff --git a/src/Twig/UserExtension.php b/src/Twig/UserExtension.php
index 869bea84..5045257a 100644
--- a/src/Twig/UserExtension.php
+++ b/src/Twig/UserExtension.php
@@ -41,18 +41,29 @@ declare(strict_types=1);
namespace App\Twig;
+use App\Entity\Base\AbstractDBElement;
+use App\Entity\UserSystem\User;
use App\Entity\LogSystem\AbstractLogEntry;
use App\Repository\LogEntryRepository;
use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
+use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
+/**
+ * @see \App\Tests\Twig\UserExtensionTest
+ */
final class UserExtension extends AbstractExtension
{
- private LogEntryRepository $repo;
+ private readonly LogEntryRepository $repo;
- public function __construct(EntityManagerInterface $em)
+ public function __construct(EntityManagerInterface $em,
+ private readonly Security $security,
+ private readonly UrlGeneratorInterface $urlGenerator)
{
$this->repo = $em->getRepository(AbstractLogEntry::class);
}
@@ -60,7 +71,7 @@ final class UserExtension extends AbstractExtension
public function getFilters(): array
{
return [
- new TwigFilter('remove_locale_from_path', [$this, 'removeLocaleFromPath']),
+ new TwigFilter('remove_locale_from_path', fn(string $path): string => $this->removeLocaleFromPath($path)),
];
}
@@ -68,19 +79,55 @@ final class UserExtension extends AbstractExtension
{
return [
/* Returns the user which has edited the given entity the last time. */
- new TwigFunction('last_editing_user', [$this->repo, 'getLastEditingUser']),
+ new TwigFunction('last_editing_user', fn(AbstractDBElement $element): ?User => $this->repo->getLastEditingUser($element)),
/* Returns the user which has created the given entity. */
- new TwigFunction('creating_user', [$this->repo, 'getCreatingUser']),
+ new TwigFunction('creating_user', fn(AbstractDBElement $element): ?User => $this->repo->getCreatingUser($element)),
+ new TwigFunction('impersonator_user', $this->getImpersonatorUser(...)),
+ new TwigFunction('impersonation_active', $this->isImpersonationActive(...)),
+ new TwigFunction('impersonation_path', $this->getImpersonationPath(...)),
];
}
/**
- * This function/filter generates an path.
+ * This function returns the user which has impersonated the current user.
+ * If the current user is not impersonated, null is returned.
+ * @return User|null
+ */
+ public function getImpersonatorUser(): ?User
+ {
+ $token = $this->security->getToken();
+ if ($token instanceof SwitchUserToken) {
+ $tmp = $token->getOriginalToken()->getUser();
+
+ if ($tmp instanceof User) {
+ return $tmp;
+ }
+ }
+
+ return null;
+ }
+
+ public function isImpersonationActive(): bool
+ {
+ return $this->security->isGranted('IS_IMPERSONATOR');
+ }
+
+ public function getImpersonationPath(User $user, string $route_name = 'homepage'): string
+ {
+ if (! $this->security->isGranted('CAN_SWITCH_USER', $user)) {
+ throw new AccessDeniedException('You are not allowed to impersonate this user!');
+ }
+
+ return $this->urlGenerator->generate($route_name, ['_switch_user' => $user->getUsername()]);
+ }
+
+ /**
+ * This function/filter generates a path.
*/
public function removeLocaleFromPath(string $path): string
{
//Ensure the path has the correct format
- if (!preg_match('/^\/\w{2}\//', $path)) {
+ if (!preg_match('/^\/\w{2}(?:_\w{2})?\//', $path)) {
throw new \InvalidArgumentException('The given path is not a localized path!');
}
diff --git a/src/Validator/Constraints/BigDecimal/BigDecimalGreaterThanValidator.php b/src/Validator/Constraints/BigDecimal/BigDecimalGreaterThanValidator.php
index 62231ea8..76acb25a 100644
--- a/src/Validator/Constraints/BigDecimal/BigDecimalGreaterThanValidator.php
+++ b/src/Validator/Constraints/BigDecimal/BigDecimalGreaterThanValidator.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Validator\Constraints\BigDecimal;
use Brick\Math\BigDecimal;
diff --git a/src/Validator/Constraints/BigDecimal/BigDecimalGreaterThenOrEqualValidator.php b/src/Validator/Constraints/BigDecimal/BigDecimalGreaterThenOrEqualValidator.php
index 3e4c8444..b6df06d0 100644
--- a/src/Validator/Constraints/BigDecimal/BigDecimalGreaterThenOrEqualValidator.php
+++ b/src/Validator/Constraints/BigDecimal/BigDecimalGreaterThenOrEqualValidator.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Validator\Constraints\BigDecimal;
use Brick\Math\BigDecimal;
diff --git a/src/Validator/Constraints/BigDecimal/BigDecimalPositive.php b/src/Validator/Constraints/BigDecimal/BigDecimalPositive.php
index bb4e61eb..0e7e755f 100644
--- a/src/Validator/Constraints/BigDecimal/BigDecimalPositive.php
+++ b/src/Validator/Constraints/BigDecimal/BigDecimalPositive.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Validator\Constraints\BigDecimal;
-use Symfony\Component\Validator\Constraints\GreaterThan;
+use Symfony\Component\Validator\Constraints\Positive;
-/**
- * @Annotation
- * @Target({"PROPERTY", "METHOD", "ANNOTATION"})
- *
- * @author Jan Schädlich
- */
-class BigDecimalPositive extends GreaterThan
+#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+class BigDecimalPositive extends Positive
{
- use BigNumberConstraintTrait;
-
- public $message = 'This value should be positive.';
-
- public function __construct($options = null)
- {
- parent::__construct($this->configureNumberConstraintOptions($options));
- }
-
public function validatedBy(): string
{
return BigDecimalGreaterThanValidator::class;
diff --git a/src/Validator/Constraints/BigDecimal/BigDecimalPositiveOrZero.php b/src/Validator/Constraints/BigDecimal/BigDecimalPositiveOrZero.php
index 1e6558a2..408cd582 100644
--- a/src/Validator/Constraints/BigDecimal/BigDecimalPositiveOrZero.php
+++ b/src/Validator/Constraints/BigDecimal/BigDecimalPositiveOrZero.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Validator\Constraints\BigDecimal;
-use Symfony\Component\Validator\Constraints\GreaterThanOrEqual;
+use Symfony\Component\Validator\Constraints\PositiveOrZero;
-/**
- * @Annotation
- * @Target({"PROPERTY", "METHOD", "ANNOTATION"})
- *
- * @author Jan Schädlich
- */
-class BigDecimalPositiveOrZero extends GreaterThanOrEqual
+#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+class BigDecimalPositiveOrZero extends PositiveOrZero
{
- use BigNumberConstraintTrait;
-
- public $message = 'This value should be either positive or zero.';
-
- public function __construct($options = null)
- {
- parent::__construct($this->configureNumberConstraintOptions($options));
- }
-
public function validatedBy(): string
{
return BigDecimalGreaterThenOrEqualValidator::class;
diff --git a/src/Validator/Constraints/BigDecimal/BigNumberConstraintTrait.php b/src/Validator/Constraints/BigDecimal/BigNumberConstraintTrait.php
deleted file mode 100644
index a9858730..00000000
--- a/src/Validator/Constraints/BigDecimal/BigNumberConstraintTrait.php
+++ /dev/null
@@ -1,49 +0,0 @@
-.
- */
-
-namespace App\Validator\Constraints\BigDecimal;
-
-use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
-
-use function is_array;
-
-trait BigNumberConstraintTrait
-{
- private function configureNumberConstraintOptions($options): array
- {
- if (null === $options) {
- $options = [];
- } elseif (!is_array($options)) {
- $options = [$this->getDefaultOption() => $options];
- }
-
- if (isset($options['propertyPath'])) {
- throw new ConstraintDefinitionException(sprintf('The "propertyPath" option of the "%s" constraint cannot be set.', static::class));
- }
-
- if (isset($options['value'])) {
- throw new ConstraintDefinitionException(sprintf('The "value" option of the "%s" constraint cannot be set.', static::class));
- }
-
- $options['value'] = 0;
-
- return $options;
- }
-}
\ No newline at end of file
diff --git a/src/Validator/Constraints/Misc/ValidRange.php b/src/Validator/Constraints/Misc/ValidRange.php
index eb14cf0d..680eb04d 100644
--- a/src/Validator/Constraints/Misc/ValidRange.php
+++ b/src/Validator/Constraints/Misc/ValidRange.php
@@ -43,10 +43,8 @@ namespace App\Validator\Constraints\Misc;
use Symfony\Component\Validator\Constraint;
-/**
- * @Annotation
- */
+#[\Attribute(\Attribute::TARGET_PROPERTY)]
class ValidRange extends Constraint
{
- public $message = 'validator.invalid_range';
+ public string $message = 'validator.invalid_range';
}
diff --git a/src/Validator/Constraints/Misc/ValidRangeValidator.php b/src/Validator/Constraints/Misc/ValidRangeValidator.php
index 8385cc92..8bc5af0c 100644
--- a/src/Validator/Constraints/Misc/ValidRangeValidator.php
+++ b/src/Validator/Constraints/Misc/ValidRangeValidator.php
@@ -49,11 +49,8 @@ use Symfony\Component\Validator\Exception\UnexpectedValueException;
class ValidRangeValidator extends ConstraintValidator
{
- protected RangeParser $rangeParser;
-
- public function __construct(RangeParser $rangeParser)
+ public function __construct(protected RangeParser $rangeParser)
{
- $this->rangeParser = $rangeParser;
}
public function validate($value, Constraint $constraint): void
diff --git a/src/Validator/Constraints/NoLockout.php b/src/Validator/Constraints/NoLockout.php
index 1a515e4d..30eb770f 100644
--- a/src/Validator/Constraints/NoLockout.php
+++ b/src/Validator/Constraints/NoLockout.php
@@ -26,10 +26,14 @@ use Symfony\Component\Validator\Constraint;
/**
* This constraint restricts a user in that way that it can not lock itself out of the user system.
- *
- * @Annotation
*/
+#[\Attribute(\Attribute::TARGET_CLASS)]
class NoLockout extends Constraint
{
- public $message = 'validator.noLockout';
+ public string $message = 'validator.noLockout';
+
+ public function getTargets(): string|array
+ {
+ return [self::CLASS_CONSTRAINT];
+ }
}
diff --git a/src/Validator/Constraints/NoLockoutValidator.php b/src/Validator/Constraints/NoLockoutValidator.php
index 4622d7fe..f3998188 100644
--- a/src/Validator/Constraints/NoLockoutValidator.php
+++ b/src/Validator/Constraints/NoLockoutValidator.php
@@ -22,28 +22,20 @@ declare(strict_types=1);
namespace App\Validator\Constraints;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\Security\Core\User\UserInterface;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use App\Services\UserSystem\PermissionManager;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\Exception\UnexpectedTypeException;
-use Symfony\Component\Security\Core\Security;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class NoLockoutValidator extends ConstraintValidator
{
- protected PermissionManager $resolver;
- protected array $perm_structure;
- protected Security $security;
- protected EntityManagerInterface $entityManager;
-
- public function __construct(PermissionManager $resolver, Security $security, EntityManagerInterface $entityManager)
+ public function __construct(protected PermissionManager $resolver, protected Security $security, protected EntityManagerInterface $entityManager)
{
- $this->resolver = $resolver;
- $this->perm_structure = $resolver->getPermissionStructure();
- $this->security = $security;
- $this->entityManager = $entityManager;
}
/**
@@ -52,7 +44,7 @@ class NoLockoutValidator extends ConstraintValidator
* @param mixed $value The value that should be validated
* @param Constraint $constraint The constraint for the validation
*/
- public function validate($value, Constraint $constraint): void
+ public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof NoLockout) {
throw new UnexpectedTypeException($constraint, NoLockout::class);
@@ -64,18 +56,20 @@ class NoLockoutValidator extends ConstraintValidator
if ($perm_holder instanceof User || $perm_holder instanceof Group) {
$user = $this->security->getUser();
- if (null === $user) {
+ if (!$user instanceof UserInterface) {
$user = $this->entityManager->getRepository(User::class)->getAnonymousUser();
}
- //Check if we the change_permission permission has changed from allow to disallow
- if (($user instanceof User) && false === ($this->resolver->inherit(
+ //Check if the change_permission permission has changed from allow to disallow
+ if (($user instanceof User) && !($this->resolver->inherit(
$user,
'users',
'edit_permissions'
) ?? false)) {
$this->context->addViolation($constraint->message);
}
+ } else {
+ throw new \LogicException('The NoLockout constraint can only be used on User or Group objects.');
}
}
}
diff --git a/src/Validator/Constraints/NoneOfItsChildren.php b/src/Validator/Constraints/NoneOfItsChildren.php
index e3bed2f8..8f1e059a 100644
--- a/src/Validator/Constraints/NoneOfItsChildren.php
+++ b/src/Validator/Constraints/NoneOfItsChildren.php
@@ -25,19 +25,18 @@ namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/**
- * Constraints the parent property on StructuralDBElement objects in the way, that neither the object self or any
+ * Constraints the parent property on StructuralDBElement objects in the way, that neither the object self nor any
* of its children can be assigned.
- *
- * @Annotation
*/
+#[\Attribute(\Attribute::TARGET_PROPERTY)]
class NoneOfItsChildren extends Constraint
{
/**
- * @var string The message used if it is tried to assign a object as its own parent
+ * @var string The message used if it is tried to assign an object as its own parent
*/
- public $self_message = 'validator.noneofitschild.self';
+ public string $self_message = 'validator.noneofitschild.self';
/**
* @var string The message used if it is tried to use one of the children for as parent
*/
- public $children_message = 'validator.noneofitschild.children';
+ public string $children_message = 'validator.noneofitschild.children';
}
diff --git a/src/Validator/Constraints/NoneOfItsChildrenValidator.php b/src/Validator/Constraints/NoneOfItsChildrenValidator.php
index 7bc3fd5a..2be5f16b 100644
--- a/src/Validator/Constraints/NoneOfItsChildrenValidator.php
+++ b/src/Validator/Constraints/NoneOfItsChildrenValidator.php
@@ -30,6 +30,7 @@ use Symfony\Component\Validator\Exception\UnexpectedValueException;
/**
* The validator for the NoneOfItsChildren annotation.
+ * @see \App\Tests\Validator\Constraints\NoneOfItsChildrenValidatorTest
*/
class NoneOfItsChildrenValidator extends ConstraintValidator
{
@@ -39,7 +40,7 @@ class NoneOfItsChildrenValidator extends ConstraintValidator
* @param mixed $value The value that should be validated
* @param Constraint $constraint The constraint for the validation
*/
- public function validate($value, Constraint $constraint): void
+ public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof NoneOfItsChildren) {
throw new UnexpectedTypeException($constraint, NoneOfItsChildren::class);
@@ -63,7 +64,7 @@ class NoneOfItsChildrenValidator extends ConstraintValidator
// Check if the targeted parent is the object itself:
$entity_id = $entity->getID();
- if (null !== $entity_id && $entity_id === $value->getID()) {
+ if ($entity === $value || (null !== $entity_id && $entity_id === $value->getID())) {
//Set the entity to a valid state
$entity->setParent(null);
$this->context->buildViolation($constraint->self_message)->addViolation();
diff --git a/src/Validator/Constraints/ProjectSystem/ValidProjectBuildRequest.php b/src/Validator/Constraints/ProjectSystem/ValidProjectBuildRequest.php
index b0c99947..1e9ac834 100644
--- a/src/Validator/Constraints/ProjectSystem/ValidProjectBuildRequest.php
+++ b/src/Validator/Constraints/ProjectSystem/ValidProjectBuildRequest.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Validator\Constraints\ProjectSystem;
use Symfony\Component\Validator\Constraint;
/**
* This constraint checks that the given ProjectBuildRequest is valid.
- *
- * @Annotation
*/
+#[\Attribute(\Attribute::TARGET_CLASS)]
class ValidProjectBuildRequest extends Constraint
{
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
-}
\ No newline at end of file
+}
diff --git a/src/Validator/Constraints/ProjectSystem/ValidProjectBuildRequestValidator.php b/src/Validator/Constraints/ProjectSystem/ValidProjectBuildRequestValidator.php
index 03a9b81f..2d59e648 100644
--- a/src/Validator/Constraints/ProjectSystem/ValidProjectBuildRequestValidator.php
+++ b/src/Validator/Constraints/ProjectSystem/ValidProjectBuildRequestValidator.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Validator\Constraints\ProjectSystem;
use App\Entity\Parts\PartLot;
@@ -36,7 +38,7 @@ class ValidProjectBuildRequestValidator extends ConstraintValidator
->setParameter('{{ lot }}', $partLot->getName());
}
- public function validate($value, Constraint $constraint)
+ public function validate($value, Constraint $constraint): void
{
if (!$constraint instanceof ValidProjectBuildRequest) {
throw new UnexpectedTypeException($constraint, ValidProjectBuildRequest::class);
@@ -67,16 +69,16 @@ class ValidProjectBuildRequestValidator extends ConstraintValidator
->addViolation();
}
- if ($withdraw_sum > $needed_amount) {
+ if ($withdraw_sum > $needed_amount && $value->isDontCheckQuantity() === false) {
$this->buildViolationForLot($lot, 'validator.project_build.lot_bigger_than_needed')
->addViolation();
}
- if ($withdraw_sum < $needed_amount) {
+ if ($withdraw_sum < $needed_amount && $value->isDontCheckQuantity() === false) {
$this->buildViolationForLot($lot, 'validator.project_build.lot_smaller_than_needed')
->addViolation();
}
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Validator/Constraints/Selectable.php b/src/Validator/Constraints/Selectable.php
index 20a51aad..c26e47fa 100644
--- a/src/Validator/Constraints/Selectable.php
+++ b/src/Validator/Constraints/Selectable.php
@@ -27,10 +27,9 @@ use Symfony\Component\Validator\Constraint;
/**
* If a property is marked with this constraint, the choosen value (of type StructuralDBElement)
* must NOT be marked as not selectable.
- *
- * @Annotation
*/
+#[\Attribute(\Attribute::TARGET_PROPERTY)]
class Selectable extends Constraint
{
- public $message = 'validator.isSelectable';
+ public string $message = 'validator.isSelectable';
}
diff --git a/src/Validator/Constraints/SelectableValidator.php b/src/Validator/Constraints/SelectableValidator.php
index 8b93865d..013a3964 100644
--- a/src/Validator/Constraints/SelectableValidator.php
+++ b/src/Validator/Constraints/SelectableValidator.php
@@ -30,6 +30,7 @@ use Symfony\Component\Validator\Exception\UnexpectedValueException;
/**
* The validator for the Selectable constraint.
+ * @see \App\Tests\Validator\Constraints\SelectableValidatorTest
*/
class SelectableValidator extends ConstraintValidator
{
@@ -39,7 +40,7 @@ class SelectableValidator extends ConstraintValidator
* @param mixed $value The value that should be validated
* @param Constraint $constraint The constraint for the validation
*/
- public function validate($value, Constraint $constraint): void
+ public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof Selectable) {
throw new UnexpectedTypeException($constraint, Selectable::class);
@@ -53,7 +54,7 @@ class SelectableValidator extends ConstraintValidator
//Check type of value. Validating only works for StructuralDBElements
if (!$value instanceof AbstractStructuralDBElement) {
- throw new UnexpectedValueException($value, 'StructuralDBElement');
+ throw new UnexpectedValueException($value, AbstractStructuralDBElement::class);
}
//Check if the value is not selectable -> show error message then.
diff --git a/src/Validator/Constraints/UniqueObjectCollection.php b/src/Validator/Constraints/UniqueObjectCollection.php
new file mode 100644
index 00000000..6548494e
--- /dev/null
+++ b/src/Validator/Constraints/UniqueObjectCollection.php
@@ -0,0 +1,64 @@
+.
+ */
+namespace App\Validator\Constraints;
+
+use InvalidArgumentException;
+use Symfony\Component\Validator\Constraint;
+
+#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+class UniqueObjectCollection extends Constraint
+{
+ public const IS_NOT_UNIQUE = '7911c98d-b845-4da0-94b7-a8dac36bc55a';
+
+ public array|string $fields = [];
+
+ protected const ERROR_NAMES = [
+ self::IS_NOT_UNIQUE => 'IS_NOT_UNIQUE',
+ ];
+
+ public string $message = 'This value is already used.';
+ public $normalizer;
+
+ /**
+ * @param array|string $fields the combination of fields that must contain unique values or a set of options
+ */
+ public function __construct(
+ ?array $options = null,
+ ?string $message = null,
+ ?callable $normalizer = null,
+ ?array $groups = null,
+ mixed $payload = null,
+ array|string|null $fields = null,
+ public bool $allowNull = true,
+ ) {
+ parent::__construct($options, $groups, $payload);
+
+ $this->message = $message ?? $this->message;
+ $this->normalizer = $normalizer ?? $this->normalizer;
+ $this->fields = $fields ?? $this->fields;
+
+ if (null !== $this->normalizer && !\is_callable($this->normalizer)) {
+ throw new InvalidArgumentException(sprintf('The "normalizer" option must be a valid callable ("%s" given).', get_debug_type($this->normalizer)));
+ }
+ }
+}
diff --git a/src/Validator/Constraints/UniqueObjectCollectionValidator.php b/src/Validator/Constraints/UniqueObjectCollectionValidator.php
new file mode 100644
index 00000000..b80889a4
--- /dev/null
+++ b/src/Validator/Constraints/UniqueObjectCollectionValidator.php
@@ -0,0 +1,114 @@
+.
+ */
+namespace App\Validator\Constraints;
+
+use App\Validator\UniqueValidatableInterface;
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\ConstraintValidator;
+use Symfony\Component\Validator\Exception\UnexpectedTypeException;
+use Symfony\Component\Validator\Exception\UnexpectedValueException;
+
+/**
+ * @see \App\Tests\Validator\Constraints\UniqueObjectCollectionValidatorTest
+ */
+class UniqueObjectCollectionValidator extends ConstraintValidator
+{
+
+ public function validate(mixed $value, Constraint $constraint): void
+ {
+ if (!$constraint instanceof UniqueObjectCollection) {
+ throw new UnexpectedTypeException($constraint, UniqueObjectCollection::class);
+ }
+
+ $fields = (array) $constraint->fields;
+
+ if (null === $value) {
+ return;
+ }
+
+ if (!\is_array($value) && !$value instanceof \IteratorAggregate) {
+ throw new UnexpectedValueException($value, 'array|IteratorAggregate');
+ }
+
+ $collectionElements = [];
+ $normalizer = $this->getNormalizer($constraint);
+ foreach ($value as $key => $object) {
+
+ if (!$object instanceof UniqueValidatableInterface) {
+ throw new UnexpectedValueException($object, UniqueValidatableInterface::class);
+ }
+
+ //Convert the object to an array using the helper function
+ $element = $object->getComparableFields();
+
+ if ($fields && !$element = $this->reduceElementKeys($fields, $element, $constraint)) {
+ continue;
+ }
+
+ $element = $normalizer($element);
+
+ if (\in_array($element, $collectionElements, true)) {
+
+ $violation = $this->context->buildViolation($constraint->message);
+
+ //Use the first supplied field as the target field, or the first defined field name of the element if none is supplied
+ $target_field = $constraint->fields[0] ?? array_keys($element)[0];
+
+ $violation->atPath('[' . $key . ']' . '.' . $target_field);
+
+ $violation->setParameter('{{ object }}', $this->formatValue($object, ConstraintValidator::OBJECT_TO_STRING))
+ ->setCode(UniqueObjectCollection::IS_NOT_UNIQUE)
+ ->addViolation();
+
+ return;
+ }
+ $collectionElements[] = $element;
+ }
+ }
+
+ private function getNormalizer(UniqueObjectCollection $unique): callable
+ {
+ return $unique->normalizer ?? static fn($value) => $value;
+ }
+
+ private function reduceElementKeys(array $fields, array $element, UniqueObjectCollection $constraint): array
+ {
+ $output = [];
+ foreach ($fields as $field) {
+ if (!\is_string($field)) {
+ throw new UnexpectedTypeException($field, 'string');
+ }
+ if (\array_key_exists($field, $element)) {
+ //Ignore null values if specified
+ if ($element[$field] === null && $constraint->allowNull) {
+ continue;
+ }
+
+ $output[$field] = $element[$field];
+ }
+ }
+
+ return $output;
+ }
+
+}
diff --git a/src/Validator/Constraints/UrlOrBuiltin.php b/src/Validator/Constraints/UrlOrBuiltin.php
index d2c5715f..ceec5d07 100644
--- a/src/Validator/Constraints/UrlOrBuiltin.php
+++ b/src/Validator/Constraints/UrlOrBuiltin.php
@@ -26,14 +26,13 @@ use App\Entity\Attachments\Attachment;
use Symfony\Component\Validator\Constraints\Url;
/**
- * Constraints the field that way that the content is either a url or a path to a builtin ressource (like %FOOTPRINTS%).
- *
- * @Annotation
+ * Constraints the field that way that the content is either an url or a path to a builtin ressource (like %FOOTPRINTS%).
*/
+#[\Attribute(\Attribute::TARGET_PROPERTY)]
class UrlOrBuiltin extends Url
{
/**
* @var array A list of the placeholders that are treated as builtin
*/
- public $allowed_placeholders = Attachment::BUILTIN_PLACEHOLDER;
+ public array $allowed_placeholders = Attachment::BUILTIN_PLACEHOLDER;
}
diff --git a/src/Validator/Constraints/UrlOrBuiltinValidator.php b/src/Validator/Constraints/UrlOrBuiltinValidator.php
index b8ad3b6a..71407a6a 100644
--- a/src/Validator/Constraints/UrlOrBuiltinValidator.php
+++ b/src/Validator/Constraints/UrlOrBuiltinValidator.php
@@ -34,6 +34,7 @@ use function is_object;
* The validator for UrlOrBuiltin.
* It checks if the value is either a builtin ressource or a valid url.
* In both cases it is not checked, if the ressource is really existing.
+ * @see \App\Tests\Validator\Constraints\UrlOrBuiltinValidatorTest
*/
class UrlOrBuiltinValidator extends UrlValidator
{
@@ -57,7 +58,7 @@ class UrlOrBuiltinValidator extends UrlValidator
//After the %PLACEHOLDER% comes a slash, so we can check if we have a placholder via explode
$tmp = explode('/', $value);
//Builtins must have a %PLACEHOLDER% construction
- if (in_array($tmp[0], $constraint->allowed_placeholders, false)) {
+ if (in_array($tmp[0], $constraint->allowed_placeholders, true)) {
return;
}
diff --git a/src/Validator/Constraints/ValidFileFilter.php b/src/Validator/Constraints/ValidFileFilter.php
index 8a7b70d0..d962c0ea 100644
--- a/src/Validator/Constraints/ValidFileFilter.php
+++ b/src/Validator/Constraints/ValidFileFilter.php
@@ -24,9 +24,7 @@ namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
-/**
- * @Annotation
- */
+#[\Attribute(\Attribute::TARGET_PROPERTY)]
class ValidFileFilter extends Constraint
{
}
diff --git a/src/Validator/Constraints/ValidFileFilterValidator.php b/src/Validator/Constraints/ValidFileFilterValidator.php
index ccce30ce..2a90a010 100644
--- a/src/Validator/Constraints/ValidFileFilterValidator.php
+++ b/src/Validator/Constraints/ValidFileFilterValidator.php
@@ -32,11 +32,8 @@ use function is_string;
class ValidFileFilterValidator extends ConstraintValidator
{
- protected FileTypeFilterTools $filterTools;
-
- public function __construct(FileTypeFilterTools $filterTools)
+ public function __construct(protected FileTypeFilterTools $filterTools)
{
- $this->filterTools = $filterTools;
}
/**
@@ -45,7 +42,7 @@ class ValidFileFilterValidator extends ConstraintValidator
* @param mixed $value The value that should be validated
* @param Constraint $constraint The constraint for the validation
*/
- public function validate($value, Constraint $constraint): void
+ public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof ValidFileFilter) {
throw new UnexpectedTypeException($constraint, ValidFileFilter::class);
diff --git a/src/Validator/Constraints/ValidGoogleAuthCode.php b/src/Validator/Constraints/ValidGoogleAuthCode.php
index 0956b961..180d346e 100644
--- a/src/Validator/Constraints/ValidGoogleAuthCode.php
+++ b/src/Validator/Constraints/ValidGoogleAuthCode.php
@@ -22,8 +22,20 @@ declare(strict_types=1);
namespace App\Validator\Constraints;
+use Scheb\TwoFactorBundle\Model\Google\TwoFactorInterface;
use Symfony\Component\Validator\Constraint;
class ValidGoogleAuthCode extends Constraint
{
+ /**
+ * @param TwoFactorInterface|null $user The user to use for the validation process, if null, the current user is used
+ */
+ public function __construct(
+ ?array $options = null,
+ ?array $groups = null,
+ mixed $payload = null,
+ public ?TwoFactorInterface $user = null)
+ {
+ parent::__construct($options, $groups, $payload);
+ }
}
diff --git a/src/Validator/Constraints/ValidGoogleAuthCodeValidator.php b/src/Validator/Constraints/ValidGoogleAuthCodeValidator.php
index bb66f395..25afe57b 100644
--- a/src/Validator/Constraints/ValidGoogleAuthCodeValidator.php
+++ b/src/Validator/Constraints/ValidGoogleAuthCodeValidator.php
@@ -22,10 +22,9 @@ declare(strict_types=1);
namespace App\Validator\Constraints;
-use App\Entity\UserSystem\User;
-use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Google\GoogleAuthenticator;
+use Scheb\TwoFactorBundle\Model\Google\TwoFactorInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Google\GoogleAuthenticatorInterface;
-use Symfony\Component\Form\FormInterface;
+use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
@@ -34,13 +33,13 @@ use Symfony\Component\Validator\Exception\UnexpectedValueException;
use function is_string;
use function strlen;
+/**
+ * @see \App\Tests\Validator\Constraints\ValidGoogleAuthCodeValidatorTest
+ */
class ValidGoogleAuthCodeValidator extends ConstraintValidator
{
- protected GoogleAuthenticatorInterface $googleAuthenticator;
-
- public function __construct(GoogleAuthenticatorInterface $googleAuthenticator)
+ public function __construct(private readonly GoogleAuthenticatorInterface $googleAuthenticator, private readonly Security $security)
{
- $this->googleAuthenticator = $googleAuthenticator;
}
public function validate($value, Constraint $constraint): void
@@ -59,23 +58,24 @@ class ValidGoogleAuthCodeValidator extends ConstraintValidator
if (!ctype_digit($value)) {
$this->context->addViolation('validator.google_code.only_digits_allowed');
+ return;
}
//Number must have 6 digits
if (6 !== strlen($value)) {
$this->context->addViolation('validator.google_code.wrong_digit_count');
+ return;
}
- //Try to retrieve the user we want to check
- if ($this->context->getObject() instanceof FormInterface &&
- $this->context->getObject()->getParent() instanceof FormInterface
- && $this->context->getObject()->getParent()->getData() instanceof User) {
- $user = $this->context->getObject()->getParent()->getData();
+ //Use the current user to check the code
+ $user = $constraint->user ?? $this->security->getUser();
+ if (!$user instanceof TwoFactorInterface) {
+ throw new UnexpectedValueException($user, TwoFactorInterface::class);
+ }
- //Check if the given code is valid
- if (!$this->googleAuthenticator->checkCode($user, $value)) {
- $this->context->addViolation('validator.google_code.wrong_code');
- }
+ //Check if the given code is valid
+ if (!$this->googleAuthenticator->checkCode($user, $value)) {
+ $this->context->addViolation('validator.google_code.wrong_code');
}
}
}
diff --git a/src/Validator/Constraints/ValidPartLot.php b/src/Validator/Constraints/ValidPartLot.php
index 3b9658ac..a82c6a10 100644
--- a/src/Validator/Constraints/ValidPartLot.php
+++ b/src/Validator/Constraints/ValidPartLot.php
@@ -27,9 +27,8 @@ use Symfony\Component\Validator\Constraint;
/**
* A constraint "dummy" to validate the PartLot.
* We need to access services in our Validator, so we can not use a simple callback on PartLot.
- *
- * @Annotation
*/
+#[\Attribute(\Attribute::TARGET_CLASS)]
class ValidPartLot extends Constraint
{
public function getTargets(): string
diff --git a/src/Validator/Constraints/ValidPartLotValidator.php b/src/Validator/Constraints/ValidPartLotValidator.php
index d77ecd0e..316fedea 100644
--- a/src/Validator/Constraints/ValidPartLotValidator.php
+++ b/src/Validator/Constraints/ValidPartLotValidator.php
@@ -23,7 +23,7 @@ declare(strict_types=1);
namespace App\Validator\Constraints;
use App\Entity\Parts\PartLot;
-use App\Entity\Parts\Storelocation;
+use App\Entity\Parts\StorageLocation;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\Exception\UnexpectedTypeException;
@@ -32,11 +32,8 @@ use Symfony\Component\Validator\ConstraintValidator;
class ValidPartLotValidator extends ConstraintValidator
{
- protected EntityManagerInterface $em;
-
- public function __construct(EntityManagerInterface $em)
+ public function __construct(protected EntityManagerInterface $em)
{
- $this->em = $em;
}
/**
@@ -45,7 +42,7 @@ class ValidPartLotValidator extends ConstraintValidator
* @param mixed $value The value that should be validated
* @param Constraint $constraint The constraint for the validation
*/
- public function validate($value, Constraint $constraint): void
+ public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof ValidPartLot) {
throw new UnexpectedTypeException($constraint, ValidPartLot::class);
@@ -56,8 +53,8 @@ class ValidPartLotValidator extends ConstraintValidator
}
//We can only validate the values if we know the storelocation
- if ($value->getStorageLocation()) {
- $repo = $this->em->getRepository(Storelocation::class);
+ if ($value->getStorageLocation() instanceof StorageLocation) {
+ $repo = $this->em->getRepository(StorageLocation::class);
//We can only determine associated parts, if the part have an ID
//When the storage location is new (no ID), we can just assume there are no other parts
if (null !== $value->getID() && $value->getStorageLocation()->getID()) {
diff --git a/src/Validator/Constraints/ValidPermission.php b/src/Validator/Constraints/ValidPermission.php
index 58a4ad2c..4d05f6cf 100644
--- a/src/Validator/Constraints/ValidPermission.php
+++ b/src/Validator/Constraints/ValidPermission.php
@@ -28,8 +28,8 @@ use Symfony\Component\Validator\Constraint;
* A PermissionEmbed object with this annotation will be checked with ValidPermissionValidator.
* That means the alsoSet values of the permission operations are set.
*
- * @Annotation
*/
+#[\Attribute(\Attribute::TARGET_PROPERTY)]
class ValidPermission extends Constraint
{
}
diff --git a/src/Validator/Constraints/ValidPermissionValidator.php b/src/Validator/Constraints/ValidPermissionValidator.php
index 9b31048f..afb7721b 100644
--- a/src/Validator/Constraints/ValidPermissionValidator.php
+++ b/src/Validator/Constraints/ValidPermissionValidator.php
@@ -22,20 +22,22 @@ declare(strict_types=1);
namespace App\Validator\Constraints;
+use App\Controller\GroupController;
+use App\Controller\UserController;
use App\Security\Interfaces\HasPermissionsInterface;
use App\Services\UserSystem\PermissionManager;
use Symfony\Component\Form\Exception\UnexpectedTypeException;
+use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
+use function Symfony\Component\Translation\t;
+
class ValidPermissionValidator extends ConstraintValidator
{
- protected PermissionManager $resolver;
- protected array $perm_structure;
-
- public function __construct(PermissionManager $resolver)
+ public function __construct(protected PermissionManager $resolver, protected RequestStack $requestStack)
{
- $this->resolver = $resolver;
}
/**
@@ -44,7 +46,7 @@ class ValidPermissionValidator extends ConstraintValidator
* @param mixed $value The value that should be validated
* @param Constraint $constraint The constraint for the validation
*/
- public function validate($value, Constraint $constraint): void
+ public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof ValidPermission) {
throw new UnexpectedTypeException($constraint, ValidPermission::class);
@@ -53,6 +55,26 @@ class ValidPermissionValidator extends ConstraintValidator
/** @var HasPermissionsInterface $perm_holder */
$perm_holder = $this->context->getObject();
- $this->resolver->ensureCorrectSetOperations($perm_holder);
+ $changed = $this->resolver->ensureCorrectSetOperations($perm_holder);
+
+ //Sending a flash message if the permissions were fixed (only if called from UserController or GroupController)
+ //This is pretty hacky and bad design but I dont see a better way without a complete rewrite of how permissions are validated
+ //on the admin pages
+ if ($changed) {
+ //Check if this was called in context of UserController
+ $request = $this->requestStack->getMainRequest();
+ if ($request === null) {
+ return;
+ }
+ //Determine the controller class (the part before the ::)
+ $controller_class = explode('::', (string) $request->attributes->get('_controller'))[0];
+
+ if (in_array($controller_class, [UserController::class, GroupController::class], true)) {
+ /** @var Session $session */
+ $session = $this->requestStack->getSession();
+ $flashBag = $session->getFlashBag();
+ $flashBag->add('warning', t('user.edit.flash.permissions_fixed'));
+ }
+ }
}
}
diff --git a/src/Validator/Constraints/ValidTheme.php b/src/Validator/Constraints/ValidTheme.php
index 70a32a20..92a19f5a 100644
--- a/src/Validator/Constraints/ValidTheme.php
+++ b/src/Validator/Constraints/ValidTheme.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/**
* A constraint to validate the theme setting of the user.
- * @Annotation
*/
+#[\Attribute(\Attribute::TARGET_PROPERTY)]
class ValidTheme extends Constraint
{
public string $message = 'validator.selected_theme_is_invalid';
-}
\ No newline at end of file
+}
diff --git a/src/Validator/Constraints/ValidThemeValidator.php b/src/Validator/Constraints/ValidThemeValidator.php
index ec437b87..713be9a5 100644
--- a/src/Validator/Constraints/ValidThemeValidator.php
+++ b/src/Validator/Constraints/ValidThemeValidator.php
@@ -1,4 +1,7 @@
.
*/
-
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
+/**
+ * @see \App\Tests\Validator\Constraints\ValidThemeValidatorTest
+ */
class ValidThemeValidator extends ConstraintValidator
{
- private array $available_themes;
-
- public function __construct(array $available_themes)
+ public function __construct(private readonly array $available_themes)
{
- $this->available_themes = $available_themes;
}
- public function validate($value, Constraint $constraint)
+ public function validate($value, Constraint $constraint): void
{
if (!$constraint instanceof ValidTheme) {
throw new UnexpectedTypeException($constraint, ValidTheme::class);
@@ -51,4 +53,4 @@ class ValidThemeValidator extends ConstraintValidator
->addViolation();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Validator/Constraints/Year2038BugWorkaround.php b/src/Validator/Constraints/Year2038BugWorkaround.php
new file mode 100644
index 00000000..04a07908
--- /dev/null
+++ b/src/Validator/Constraints/Year2038BugWorkaround.php
@@ -0,0 +1,41 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Validator\Constraints;
+
+use Symfony\Component\Validator\Constraint;
+
+/**
+ * Datetime interfaces properties with this constraint are limited to the year 2038 on 32-bit systems, to prevent a
+ * Year 2038 bug during rendering.
+ *
+ * Current PHP versions can not format dates after 2038 on 32-bit systems and throw an exception.
+ * (See https://github.com/Part-DB/Part-DB-server/discussions/548).
+ *
+ * This constraint does not fix that problem, but can prevent users from entering such invalid dates.
+ */
+#[\Attribute(\Attribute::TARGET_PROPERTY)]
+class Year2038BugWorkaround extends Constraint
+{
+ public string $message = 'validator.year_2038_bug_on_32bit';
+}
\ No newline at end of file
diff --git a/src/Validator/Constraints/Year2038BugWorkaroundValidator.php b/src/Validator/Constraints/Year2038BugWorkaroundValidator.php
new file mode 100644
index 00000000..747721f9
--- /dev/null
+++ b/src/Validator/Constraints/Year2038BugWorkaroundValidator.php
@@ -0,0 +1,74 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\Validator\Constraints;
+
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\ConstraintValidator;
+use Symfony\Component\Validator\Exception\UnexpectedTypeException;
+
+class Year2038BugWorkaroundValidator extends ConstraintValidator
+{
+
+ public function __construct(
+ #[Autowire(env: "DISABLE_YEAR2038_BUG_CHECK")]
+ private readonly bool $disable_validation = false
+ )
+ {
+ }
+
+ public function isActivated(): bool
+ {
+ //If we are on a 32 bit system and the validation is not disabled, we should activate the validation
+ return !$this->disable_validation && PHP_INT_SIZE === 4;
+ }
+
+ public function validate(mixed $value, Constraint $constraint): void
+ {
+ if (!$this->isActivated()) {
+ return;
+ }
+
+ //If the value is null, we don't need to validate it
+ if ($value === null) {
+ return;
+ }
+
+ //Ensure that we check the correct constraint
+ if (!$constraint instanceof Year2038BugWorkaround) {
+ throw new \InvalidArgumentException('This validator can only validate Year2038Bug constraints');
+ }
+
+ //We can only validate DateTime objects
+ if (!$value instanceof \DateTimeInterface) {
+ throw new UnexpectedTypeException($value, \DateTimeInterface::class);
+ }
+
+ //If we reach here the validation is active and we should forbid any date after 2038.
+ if ($value->diff(new \DateTime('2038-01-19 03:14:06'))->invert === 1) {
+ $this->context->buildViolation($constraint->message)
+ ->addViolation();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Validator/UniqueValidatableInterface.php b/src/Validator/UniqueValidatableInterface.php
new file mode 100644
index 00000000..3d954490
--- /dev/null
+++ b/src/Validator/UniqueValidatableInterface.php
@@ -0,0 +1,34 @@
+.
+ */
+namespace App\Validator;
+
+interface UniqueValidatableInterface
+{
+ /**
+ * This method should return an array of fields that should be used to compare the objects for the UniqueObjectCollection constraint.
+ * All instances of the same class should return the same fields.
+ * The value must be a comparable value (e.g. string, int, float, bool, null).
+ * @return array An array of the form ['field1' => 'value1', 'field2' => 'value2', ...]
+ */
+ public function getComparableFields(): array;
+}
diff --git a/symfony.lock b/symfony.lock
index d453ff2e..c7471b73 100644
--- a/symfony.lock
+++ b/symfony.lock
@@ -1,9 +1,17 @@
{
- "amphp/amp": {
- "version": "v2.2.1"
- },
- "amphp/byte-stream": {
- "version": "v1.6.1"
+ "api-platform/core": {
+ "version": "3.2",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "3.2",
+ "ref": "696d44adc3c0d4f5d25a2f1c4f3700dd8a5c6db9"
+ },
+ "files": [
+ "config/packages/api_platform.yaml",
+ "config/routes/api_platform.yaml",
+ "src/ApiResource/.gitignore"
+ ]
},
"beberlei/assert": {
"version": "v3.2.6"
@@ -20,40 +28,16 @@
"composer/package-versions-deprecated": {
"version": "1.11.99.4"
},
- "composer/pcre": {
- "version": "1.0.0"
- },
- "composer/semver": {
- "version": "1.5.0"
- },
- "composer/xdebug-handler": {
- "version": "1.3.3"
- },
"dama/doctrine-test-bundle": {
- "version": "4.0",
+ "version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
- "branch": "master",
- "version": "4.0",
- "ref": "56eaa387b5e48ebcc7c95a893b47dfa1ad51449c"
+ "branch": "main",
+ "version": "7.2",
+ "ref": "896306d79d4ee143af9eadf9b09fd34a8c391b70"
},
"files": [
- "./config/packages/test/dama_doctrine_test_bundle.yaml"
- ]
- },
- "dnoegel/php-xdg-base-dir": {
- "version": "v0.1.1"
- },
- "doctrine/annotations": {
- "version": "1.0",
- "recipe": {
- "repo": "github.com/symfony/recipes",
- "branch": "master",
- "version": "1.0",
- "ref": "a2759dd6123694c8d901d0ec80006e044c2e6457"
- },
- "files": [
- "./config/routes/annotations.yaml"
+ "./config/packages/dama_doctrine_test_bundle.yaml"
]
},
"doctrine/cache": {
@@ -62,9 +46,6 @@
"doctrine/collections": {
"version": "v1.5.0"
},
- "doctrine/common": {
- "version": "v2.10.0"
- },
"doctrine/data-fixtures": {
"version": "v1.3.2"
},
@@ -75,17 +56,17 @@
"version": "v0.5.3"
},
"doctrine/doctrine-bundle": {
- "version": "2.8",
+ "version": "2.11",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
- "version": "2.4",
- "ref": "013b823e7fee65890b23e40f31e6667a1ac519ac"
+ "version": "2.10",
+ "ref": "c170ded8fc587d6bd670550c43dafcf093762245"
},
"files": [
- "config/packages/doctrine.yaml",
- "src/Entity/.gitignore",
- "src/Repository/.gitignore"
+ "./config/packages/doctrine.yaml",
+ "./src/Entity/.gitignore",
+ "./src/Repository/.gitignore"
]
},
"doctrine/doctrine-fixtures-bundle": {
@@ -149,12 +130,6 @@
"erusev/parsedown": {
"version": "1.7.4"
},
- "felixfbecker/advanced-json-rpc": {
- "version": "v3.0.4"
- },
- "felixfbecker/language-server-protocol": {
- "version": "v1.4.0"
- },
"florianv/exchanger": {
"version": "1.4.1"
},
@@ -162,25 +137,37 @@
"version": "3.5.0"
},
"florianv/swap-bundle": {
- "version": "5.0.0"
- },
- "friendsofphp/proxy-manager-lts": {
- "version": "v1.0.5"
+ "version": "5.0.x-dev"
},
"gregwar/captcha": {
"version": "v1.1.7"
},
"gregwar/captcha-bundle": {
- "version": "v2.0.6"
+ "version": "v2.2.0"
},
"imagine/imagine": {
"version": "1.2.2"
},
"jbtronics/2fa-webauthn": {
- "version": "dev-master"
+ "version": "v2.2.1"
},
- "laminas/laminas-code": {
- "version": "3.4.1"
+ "jbtronics/dompdf-font-loader-bundle": {
+ "version": "v1.1.1"
+ },
+ "jbtronics/translation-editor-bundle": {
+ "version": "v1.0"
+ },
+ "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"
+ ]
},
"league/html-to-markdown": {
"version": "4.8.2"
@@ -204,6 +191,21 @@
"monolog/monolog": {
"version": "1.24.0"
},
+ "nbgrp/onelogin-saml-bundle": {
+ "version": "v1.4.0"
+ },
+ "nelmio/cors-bundle": {
+ "version": "2.3",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "1.5",
+ "ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
+ },
+ "files": [
+ "./config/packages/nelmio_cors.yaml"
+ ]
+ },
"nelmio/security-bundle": {
"version": "2.4",
"recipe": {
@@ -216,18 +218,12 @@
"./config/packages/nelmio_security.yaml"
]
},
- "netresearch/jsonmapper": {
- "version": "v1.6.0"
- },
"nikic/php-parser": {
"version": "v4.2.1"
},
"nikolaposa/version": {
"version": "2.2.2"
},
- "nyholm/nsa": {
- "version": "1.1.0"
- },
"nyholm/psr7": {
"version": "1.0",
"recipe": {
@@ -259,7 +255,16 @@
"version": "v0.3.3"
},
"php-http/discovery": {
- "version": "1.7.0"
+ "version": "1.18",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "1.18",
+ "ref": "f45b5dd173a27873ab19f5e3180b2f661c21de02"
+ },
+ "files": [
+ "./config/packages/http_discovery.yaml"
+ ]
},
"php-http/httplug": {
"version": "v2.0.0"
@@ -270,30 +275,6 @@
"php-http/promise": {
"version": "v1.0.0"
},
- "php-translation/common": {
- "version": "1.0.0"
- },
- "php-translation/extractor": {
- "version": "1.7.1"
- },
- "php-translation/symfony-bundle": {
- "version": "0.12",
- "recipe": {
- "repo": "github.com/symfony/recipes-contrib",
- "branch": "master",
- "version": "0.10",
- "ref": "f3ca4e4da63897d177e58da78626c20648c0e102"
- },
- "files": [
- "config/packages/dev/php_translation.yaml",
- "config/packages/php_translation.yaml",
- "config/routes/dev/php_translation.yaml",
- "config/routes/php_translation.yaml"
- ]
- },
- "php-translation/symfony-storage": {
- "version": "1.0.1"
- },
"phpdocumentor/reflection-common": {
"version": "1.0.1"
},
@@ -307,7 +288,16 @@
"version": "1.0.3"
},
"phpstan/phpstan": {
- "version": "0.12.8"
+ "version": "1.10",
+ "recipe": {
+ "repo": "github.com/symfony/recipes-contrib",
+ "branch": "main",
+ "version": "1.0",
+ "ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
+ },
+ "files": [
+ "phpstan.dist.neon"
+ ]
},
"phpstan/phpstan-doctrine": {
"version": "0.12.9"
@@ -315,8 +305,19 @@
"phpstan/phpstan-symfony": {
"version": "0.12.4"
},
- "psalm/plugin-symfony": {
- "version": "v1.2.1"
+ "phpunit/phpunit": {
+ "version": "9.6",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "9.6",
+ "ref": "7364a21d87e658eb363c5020c072ecfdc12e2326"
+ },
+ "files": [
+ "./.env.test",
+ "./phpunit.xml.dist",
+ "./tests/bootstrap.php"
+ ]
},
"psr/cache": {
"version": "1.0.1"
@@ -361,38 +362,23 @@
"version": "8.3.0"
},
"scheb/2fa-bundle": {
- "version": "5.13",
+ "version": "6.8",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
- "version": "5.0",
- "ref": "0a83961ef50ff91812b229a6f0caf28431d94aec"
+ "version": "6.0",
+ "ref": "1e6f68089146853a790b5da9946fc5974f6fcd49"
},
"files": [
- "./config/packages/scheb_2fa.yaml",
- "./config/routes/scheb_2fa.yaml"
+ "config/packages/scheb_2fa.yaml",
+ "config/routes/scheb_2fa.yaml"
]
},
"sebastian/diff": {
"version": "3.0.2"
},
- "sensio/framework-extra-bundle": {
- "version": "5.2",
- "recipe": {
- "repo": "github.com/symfony/recipes",
- "branch": "master",
- "version": "5.2",
- "ref": "fb7e19da7f013d0d422fa9bce16f5c510e27609b"
- },
- "files": [
- "./config/packages/sensio_framework_extra.yaml"
- ]
- },
"shivas/versioning-bundle": {
- "version": "3.1.3"
- },
- "spomky-labs/cbor-bundle": {
- "version": "v2.0.3"
+ "version": "4.0.3"
},
"symfony/apache-pack": {
"version": "1.0",
@@ -400,10 +386,10 @@
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.0",
- "ref": "efb318193e48384eb5c5aadff15396ed698f8ffc"
+ "ref": "0f18b4decdf5695d692c1d0dfd65516a07a6adf1"
},
"files": [
- "public/.htaccess"
+ "./public/.htaccess"
]
},
"symfony/asset": {
@@ -422,15 +408,15 @@
"version": "v4.2.3"
},
"symfony/console": {
- "version": "5.3",
+ "version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
- "branch": "master",
+ "branch": "main",
"version": "5.3",
- "ref": "da0c8be8157600ad34f10ff0c9cc91232522e047"
+ "ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
},
"files": [
- "./bin/console"
+ "bin/console"
]
},
"symfony/css-selector": {
@@ -482,27 +468,28 @@
"version": "v4.2.3"
},
"symfony/flex": {
- "version": "1.19",
+ "version": "2.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
- "version": "1.0",
- "ref": "146251ae39e06a95be0fe3d13c807bcf3938b172"
+ "version": "2.4",
+ "ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
},
"files": [
- ".env"
+ ".env",
+ ".env.dev"
]
},
"symfony/form": {
"version": "v4.2.3"
},
"symfony/framework-bundle": {
- "version": "5.4",
+ "version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
- "branch": "master",
- "version": "5.4",
- "ref": "3cd216a4d007b78d8554d44a5b1c0a446dab24fb"
+ "branch": "main",
+ "version": "6.4",
+ "ref": "a91c965766ad3ff2ae15981801643330eb42b6a5"
},
"files": [
"config/packages/cache.yaml",
@@ -530,28 +517,16 @@
"symfony/intl": {
"version": "v4.2.3"
},
- "symfony/lock": {
- "version": "5.4",
- "recipe": {
- "repo": "github.com/symfony/recipes",
- "branch": "main",
- "version": "5.2",
- "ref": "8e937ff2b4735d110af1770f242c1107fdab4c8e"
- },
- "files": [
- "./config/packages/lock.yaml"
- ]
- },
"symfony/mailer": {
- "version": "5.4",
+ "version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "4.3",
- "ref": "2bf89438209656b85b9a49238c4467bff1b1f939"
+ "ref": "df66ee1f226c46f01e85c29c2f7acce0596ba35a"
},
"files": [
- "config/packages/mailer.yaml"
+ "./config/packages/mailer.yaml"
]
},
"symfony/maker-bundle": {
@@ -570,12 +545,12 @@
"version": "v4.4.2"
},
"symfony/monolog-bundle": {
- "version": "3.7",
+ "version": "3.10",
"recipe": {
"repo": "github.com/symfony/recipes",
- "branch": "master",
+ "branch": "main",
"version": "3.7",
- "ref": "213676c4ec929f046dfde5ea8e97625b81bc0578"
+ "ref": "aff23899c4440dd995907613c1dd709b6f59503f"
},
"files": [
"./config/packages/monolog.yaml"
@@ -588,18 +563,18 @@
"version": "v5.3.8"
},
"symfony/phpunit-bridge": {
- "version": "5.4",
+ "version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
- "version": "5.3",
- "ref": "819d3d2ffa4590eba0b8f4f3e5e89415ee4e45c3"
+ "version": "6.3",
+ "ref": "a411a0480041243d97382cac7984f7dce7813c08"
},
"files": [
- ".env.test",
- "bin/phpunit",
- "phpunit.xml.dist",
- "tests/bootstrap.php"
+ "./.env.test",
+ "./bin/phpunit",
+ "./phpunit.xml.dist",
+ "./tests/bootstrap.php"
]
},
"symfony/polyfill-ctype": {
@@ -620,18 +595,9 @@
"symfony/polyfill-mbstring": {
"version": "v1.10.0"
},
- "symfony/polyfill-php72": {
- "version": "v1.10.0"
- },
- "symfony/polyfill-php73": {
- "version": "v1.11.0"
- },
"symfony/polyfill-php80": {
"version": "v1.17.0"
},
- "symfony/polyfill-php81": {
- "version": "v1.23.0"
- },
"symfony/process": {
"version": "v4.2.3"
},
@@ -641,16 +607,13 @@
"symfony/property-info": {
"version": "v4.2.3"
},
- "symfony/proxy-manager-bridge": {
- "version": "v5.2.1"
- },
"symfony/routing": {
- "version": "5.4",
+ "version": "6.2",
"recipe": {
"repo": "github.com/symfony/recipes",
- "branch": "master",
- "version": "5.3",
- "ref": "85de1d8ae45b284c3c84b668171d2615049e698f"
+ "branch": "main",
+ "version": "6.2",
+ "ref": "e0a11b4ccb8c9e70b574ff5ad3dfdcd41dec5aa6"
},
"files": [
"config/packages/routing.yaml",
@@ -661,15 +624,16 @@
"version": "v5.3.4"
},
"symfony/security-bundle": {
- "version": "5.4",
+ "version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
- "version": "5.3",
- "ref": "98f1f2b0d635908c2b40f3675da2d23b1a069d30"
+ "version": "6.4",
+ "ref": "2ae08430db28c8eb4476605894296c82a642028f"
},
"files": [
- "config/packages/security.yaml"
+ "config/packages/security.yaml",
+ "config/routes/security.yaml"
]
},
"symfony/security-core": {
@@ -678,9 +642,6 @@
"symfony/security-csrf": {
"version": "v4.2.3"
},
- "symfony/security-guard": {
- "version": "v4.2.3"
- },
"symfony/security-http": {
"version": "v4.2.3"
},
@@ -690,6 +651,20 @@
"symfony/service-contracts": {
"version": "v1.1.5"
},
+ "symfony/stimulus-bundle": {
+ "version": "2.16",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "2.13",
+ "ref": "6acd9ff4f7fd5626d2962109bd4ebab351d43c43"
+ },
+ "files": [
+ "./assets/bootstrap.js",
+ "./assets/controllers.json",
+ "./assets/controllers/hello_controller.js"
+ ]
+ },
"symfony/stopwatch": {
"version": "v4.2.3"
},
@@ -697,12 +672,12 @@
"version": "v5.1.0"
},
"symfony/translation": {
- "version": "5.3",
+ "version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
- "branch": "master",
- "version": "5.3",
- "ref": "da64f5a2b6d96f5dc24914517c0350a5f91dee43"
+ "branch": "main",
+ "version": "6.3",
+ "ref": "e28e27f53663cc34f0be2837aba18e3a1bef8e7b"
},
"files": [
"./config/packages/translation.yaml",
@@ -716,20 +691,47 @@
"version": "v4.2.3"
},
"symfony/twig-bundle": {
- "version": "5.4",
+ "version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
- "branch": "master",
- "version": "5.4",
- "ref": "bb2178c57eee79e6be0b297aa96fc0c0def81387"
+ "branch": "main",
+ "version": "6.4",
+ "ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877"
},
"files": [
- "config/packages/twig.yaml",
- "templates/base.html.twig"
+ "./config/packages/twig.yaml",
+ "./templates/base.html.twig"
+ ]
+ },
+ "symfony/uid": {
+ "version": "6.2",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "6.2",
+ "ref": "d294ad4add3e15d7eb1bae0221588ca89b38e558"
+ },
+ "files": [
+ "./config/packages/uid.yaml"
+ ]
+ },
+ "symfony/ux-translator": {
+ "version": "2.9",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "2.9",
+ "ref": "bc396565cc4cab95692dd6df810553dc22e352e1"
+ },
+ "files": [
+ "./assets/translator.js",
+ "./config/packages/ux_translator.yaml",
+ "./var/translations/configuration.js",
+ "./var/translations/index.js"
]
},
"symfony/ux-turbo": {
- "version": "v2.0.1"
+ "version": "v2.16.0"
},
"symfony/validator": {
"version": "5.4",
@@ -753,12 +755,12 @@
"version": "v4.2.3"
},
"symfony/web-profiler-bundle": {
- "version": "5.4",
+ "version": "6.3",
"recipe": {
"repo": "github.com/symfony/recipes",
- "branch": "master",
- "version": "5.3",
- "ref": "24bbc3d84ef2f427f82104f766014e799eefcc3e"
+ "branch": "main",
+ "version": "6.1",
+ "ref": "e42b3f0177df239add25373083a564e5ead4e13a"
},
"files": [
"config/packages/web_profiler.yaml",
@@ -766,18 +768,15 @@
]
},
"symfony/webpack-encore-bundle": {
- "version": "1.16",
+ "version": "2.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
- "version": "1.10",
- "ref": "f8fc53f1942f76679e9ee3c25fd44865355707b5"
+ "version": "2.0",
+ "ref": "082d754b3bd54b3fc669f278f1eea955cfd23cf5"
},
"files": [
"assets/app.js",
- "assets/bootstrap.js",
- "assets/controllers.json",
- "assets/controllers/hello_controller.js",
"assets/styles/app.css",
"config/packages/webpack_encore.yaml",
"package.json",
@@ -803,7 +802,7 @@
"version": "v3.0.0"
},
"twig/extra-bundle": {
- "version": "v3.0.0"
+ "version": "v3.8.0"
},
"twig/html-extra": {
"version": "v3.0.3"
@@ -823,17 +822,18 @@
"ua-parser/uap-php": {
"version": "v3.9.8"
},
- "vimeo/psalm": {
- "version": "3.5.1"
- },
"web-auth/webauthn-symfony-bundle": {
- "version": "3.3",
+ "version": "4.7",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "3.0",
- "ref": "9926090a80c2cceeffe96e6c3312b397ea55d4a7"
- }
+ "ref": "a5dff33bd46575bea263af94069650af7742dcb6"
+ },
+ "files": [
+ "config/packages/webauthn.yaml",
+ "config/routes/webauthn_routes.yaml"
+ ]
},
"webmozart/assert": {
"version": "1.4.0"
diff --git a/templates/_navbar.html.twig b/templates/_navbar.html.twig
index 14e40151..cd1f641f 100644
--- a/templates/_navbar.html.twig
+++ b/templates/_navbar.html.twig
@@ -1,12 +1,20 @@
{% import "helper.twig" as helper %}
+{% import "components/search.macro.html.twig" as search %}
-
-{% endmacro parameters_table %}
\ No newline at end of file
+{% endmacro parameters_table %}
+
+{% macro format_date_nullable(datetime) %}
+ {% if datetime is null %}
+ {% trans %}datetime.never{% endtrans %}
+ {% else %}
+ {{ datetime|format_datetime }}
+ {% endif %}
+{% endmacro %}
\ No newline at end of file
diff --git a/templates/homepage.html.twig b/templates/homepage.html.twig
index 03ec48f5..68adb59f 100644
--- a/templates/homepage.html.twig
+++ b/templates/homepage.html.twig
@@ -1,7 +1,21 @@
{% extends "base.html.twig" %}
+{% import "components/new_version.macro.html.twig" as nv %}
+{% import "components/search.macro.html.twig" as search %}
+
{% block content %}
-
{% trans %}version.caption{% endtrans %}: {{ shivas_app_version }}
@@ -46,11 +60,11 @@
Jan Böhmer
. Part-DB is published under the GNU Affero General Public License v3.0 (or later), so it comes with ABSOLUTELY NO WARRANTY.
This is free software, and you are welcome to redistribute it under certain conditions.
- Click here for details.
+ Click here for details.
- {% trans %}homepage.github.caption{% endtrans %}: {% trans with {'%href%': 'https://github.com/Part-DB/Part-DB-symfony'}%}homepage.github.text{% endtrans %}
+ {% trans %}homepage.github.caption{% endtrans %}: {% trans with {'%href%': 'https://github.com/Part-DB/Part-DB-server'}%}homepage.github.text{% endtrans %} {% trans %}homepage.help.caption{% endtrans %}: {% trans with {'%href%': 'https://docs.part-db.de/'}%}homepage.help.text{% endtrans %}
- {% trans %}homepage.forum.caption{% endtrans %}: {% trans with {'%href%': 'https://github.com/Part-DB/Part-DB-symfony/discussions'}%}homepage.forum.text{% endtrans %}
+ {% trans %}homepage.forum.caption{% endtrans %}: {% trans with {'%href%': 'https://github.com/Part-DB/Part-DB-server/discussions'}%}homepage.forum.text{% endtrans %}
+
+ {% for provider in providers %}
+ {# @var provider \App\Services\InfoProviderSystem\Providers\InfoProviderInterface #}
+
+
+
+
+
+ {% 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 %}
+
+
+ {% endfor %}
+
+
+{% 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 %}
+
+
+
+ {% if update_target %} {# We update an existing part #}
+ {% set href = path('info_providers_update_part',
+ {'providerKey': dto.provider_key, 'providerId': dto.provider_id, 'id': update_target.iD}) %}
+ {% else %} {# Create a fresh part #}
+ {% set href = path('info_providers_create_part',
+ {'providerKey': dto.provider_key, 'providerId': dto.provider_id}) %}
+ {% endif %}
+
+ {# If we have no local part, then we can just show the create button #}
+ {% if localPart is null %}
+
+
+
+ {% else %} {# Otherwise add a button group with all three buttons #}
+
+
+ {% trans %}info_providers.search.existing_part_found.short{% endtrans %}
+
+
+
{{ profile.name ?? '-' }}
- {% if profile %}
+ {% if profile and is_granted("edit", profile) %}
{% endif %}
@@ -77,10 +77,10 @@
{% if is_granted("@labels.create_labels") %}
- {% for type in constant("App\\Entity\\LabelSystem\\LabelOptions::SUPPORTED_ELEMENTS") %}
+ {% for type in enum_cases("App\\Entity\\LabelSystem\\LabelSupportedElement") %}
{% set profiles = label_profile_dropdown_helper.dropdownProfiles(type) %}
{% if profiles is not empty %}
-
{{ (type~'.label') | trans }}
+
{{ (type.value~'.label') | trans }}
{% endif %}
{% for profile in profiles %}
{{ profile.name }}
@@ -91,6 +91,25 @@
+
+ {% if is_granted("@labels.read_profiles") %}
+
+
diff --git a/templates/label_system/labels/base_label.html.twig b/templates/label_system/labels/base_label.html.twig
index da6a3e5c..494b99e4 100644
--- a/templates/label_system/labels/base_label.html.twig
+++ b/templates/label_system/labels/base_label.html.twig
@@ -8,17 +8,18 @@
{% for element in elements %}
- {% if options.barcodeType == 'none' %}
+ {% if options.barcodeType.none %}
{% include "label_system/labels/label_page_none.html.twig" %}
- {% elseif options.barcodeType in ['qr', 'datamatrix'] %}
+ {% elseif options.barcodeType.is2D() %}
{% include "label_system/labels/label_page_qr.html.twig" %}
- {% elseif options.barcodeType in ['code39', 'code93', 'code128'] %}
+ {% elseif options.barcodeType.is1D() %}
{% include "label_system/labels/label_page_1d.html.twig" %}
{% endif %}
+ {% trans %}edit.log_comment{% endtrans %}:
+ {% if entry.comment %}
+ {{ entry.comment }}
+ {% else %}
+ {% trans %}log.no_comment{% endtrans %}
+ {% endif %}
+
+{% endmacro %}
+
+{% macro translate_field(field) %}
+ {% set trans_key = 'log.element_edited.changed_fields.'~field %}
+ {# If the translation key is not found, the translation key is returned, and we dont show the translation #}
+ {% if trans_key|trans != trans_key %}
+ {{ ('log.element_edited.changed_fields.'~field) | trans }}
+ ({{ field }})
+ {% else %}
+ {{ field }}
+ {% endif %}
+{% endmacro %}
+
+{% macro data_change_table(entry) %}
+ {# @var entry \App\Entity\LogSystem\ElementEditedLogEntry|\App\Entity\LogSystem\ElementDeletedLogEntry entry #}
+
+ {% set fields, old_data, new_data = {}, {}, {} %}
+
+ {# For log entries where only the changed fields are saved, this is the last executed assignment #}
+ {% if attribute(entry, 'changedFieldInfo') is defined and entry.changedFieldsInfo %}
+ {% set fields = entry.changedFields %}
+ {% endif %}
+
+ {# For log entries, where we know the old data, this is the last exectuted assignment #}
+ {% if attribute(entry, 'oldDataInformation') is defined and entry.oldDataInformation %}
+ {# We have to use the keys of oldData here, as changedFields might not be available #}
+ {% set fields = entry.oldData | keys %}
+ {% set old_data = entry.oldData %}
+ {% endif %}
+
+ {# For log entries, where we have new data, we define it #}
+ {% if attribute(entry, 'newDataInformation') is defined and entry.newDataInformation %}
+ {# We have to use the keys of oldData here, as changedFields might not be available #}
+ {% set fields = entry.newData | keys %}
+ {% set new_data = entry.newData %}
+ {% endif %}
+
+ {% if fields is not empty %}
+
+
+
+
{% trans %}log.element_changed.field{% endtrans %}
+ {% if old_data is not empty %}
+
{% trans %}log.element_changed.data_before{% endtrans %}
+ {% endif %}
+ {% if new_data is not empty %}
+
{% trans %}log.element_changed.data_after{% endtrans %}
+ {% endif %}
+ {% if new_data is not empty and old_data is not empty %} {# Diff column #}
+
{% trans %}log.element_changed.diff{% endtrans %}
+ {% endif %}
+
+
+
+ {% for field in fields %}
+
+
+ {{ _self.translate_field(field) }}
+
+ {% if old_data is not empty %}
+
+ {% if old_data[field] is defined %}
+ {{ format_log_data(old_data[field], entry, field) }}
+ {% endif %}
+
+ {% endif %}
+ {% if new_data is not empty %}
+
+ {% if new_data[field] is defined %}
+ {{ format_log_data(new_data[field], entry, field) }}
+ {% endif %}
+
+ {% endif %}
+
+ {% if new_data is not empty and old_data is not empty %}
+
+ {% if new_data[field] is defined and old_data[field] is defined %}
+ {{ format_log_diff(old_data[field], new_data[field]) }}
+ {% endif %}
+
+ {% endif %}
+
+ {% endfor %}
+
+
+ {% endif %}
+{% endmacro %}
\ No newline at end of file
diff --git a/templates/log_system/details/log_details.html.twig b/templates/log_system/details/log_details.html.twig
new file mode 100644
index 00000000..aff127f4
--- /dev/null
+++ b/templates/log_system/details/log_details.html.twig
@@ -0,0 +1,117 @@
+{% extends "main_card.html.twig" %}
+
+{% import "helper.twig" as helper %}
+{% import "log_system/details/helper.macro.html.twig" as log_helper %}
+
+{% block title %}
+ {% trans %}log.details.title{% endtrans %}:
+ {{ ('log.type.' ~ log_entry.type) | trans }} ({{ log_entry.timestamp | format_datetime('short') }})
+{% endblock %}
+
+{% block card_title %}
+
+ {% trans %}log.details.title{% endtrans %}:
+ {{ ('log.type.' ~ log_entry.type) | trans }} ({{ log_entry.timestamp | format_datetime('short') }})
+ ID: {{ log_entry.iD }}
+{% endblock %}
+
+{% block card_body %}
+
+ {% if log_entry is instanceof('App\\Entity\\LogSystem\\CollectionElementDeleted')
+ or log_entry is instanceof('App\\Entity\\LogSystem\\ElementDeletedLogEntry')
+ or log_entry is instanceof('App\\Entity\\LogSystem\\ElementCreatedLogEntry')
+ or log_entry is instanceof('App\\Entity\\LogSystem\\ElementEditedLogEntry')
+ %}
+ {{ log_helper.undo_buttons(log_entry, target_element) }}
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {# This assignment is to improve autocomplete on the subpages, as PHPstorm ignores typehints for log_entry #}
+ {% set entry = log_entry %}
+ {% if log_entry is instanceof('App\\Entity\\LogSystem\\DatabaseUpdatedLogEntry') %}
+ {% include "log_system/details/_extra_database_updated.html.twig" %}
+ {% elseif log_entry is instanceof('App\\Entity\\LogSystem\\ElementCreatedLogEntry') %}
+ {% include "log_system/details/_extra_element_created.html.twig" %}
+ {% elseif log_entry is instanceof('App\\Entity\\LogSystem\\ElementEditedLogEntry') %}
+ {% include "log_system/details/_extra_element_edited.html.twig" %}
+ {% elseif log_entry is instanceof('App\\Entity\\LogSystem\\ElementDeletedLogEntry') %}
+ {% include "log_system/details/_extra_element_deleted.html.twig" %}
+ {% elseif log_entry is instanceof('App\\Entity\\LogSystem\\UserLoginLogEntry')
+ or log_entry is instanceof('App\\Entity\\LogSystem\\UserLogoutLogEntry') %}
+ {% include "log_system/details/_extra_user_login.html.twig" %}
+ {% elseif log_entry is instanceof('App\\Entity\\LogSystem\\UserNotAllowedLogEntry') %}
+ {% include "log_system/details/_extra_user_not_allowed.html.twig" %}
+ {% elseif log_entry is instanceof('App\\Entity\\LogSystem\\SecurityEventLogEntry') %}
+ {% include "log_system/details/_extra_security_event.html.twig" %}
+ {% elseif log_entry is instanceof('App\\Entity\\LogSystem\\CollectionElementDeleted') %}
+ {% include "log_system/details/_extra_collection_element_deleted.html.twig" %}
+ {% else %}
+ {{ extra_html | raw }}
+ {% endif %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/templates/mail/pw_reset.html.twig b/templates/mail/pw_reset.html.twig
index 4a2794da..18a867d6 100644
--- a/templates/mail/pw_reset.html.twig
+++ b/templates/mail/pw_reset.html.twig
@@ -3,7 +3,7 @@
{% block content %}
-
{% trans with {'%name%': user.fullName} %}email.hi %name%{% endtrans %},
+
{% trans with {'%name%': user.fullName|escape } %}email.hi %name%{% endtrans %},
{% trans %}eda_info.kicad_section.title{% endtrans %}:
+
+
+{{ form_row(form.eda_info.kicad_symbol) }}
+{{ form_row(form.eda_info.kicad_footprint) }}
\ No newline at end of file
diff --git a/templates/parts/edit/_orderdetails.html.twig b/templates/parts/edit/_orderdetails.html.twig
index 95f0dfef..f8d766d2 100644
--- a/templates/parts/edit/_orderdetails.html.twig
+++ b/templates/parts/edit/_orderdetails.html.twig
@@ -1,5 +1,5 @@
{# Leave this template at bootstrap 4 for now, as it otherwise destroys our layout #}
-{% form_theme form.orderdetails with ['parts/edit/edit_form_styles.html.twig', "bootstrap_4_layout.html.twig"] %}
+{% form_theme form.orderdetails with ['parts/edit/edit_form_styles.html.twig', "bootstrap_5_layout.html.twig"] %}
{% import 'components/collection_type.macro.html.twig' as collection %}
\ No newline at end of file
diff --git a/templates/parts/info/_main_infos.html.twig b/templates/parts/info/_main_infos.html.twig
index fdbe3ab0..bced5624 100644
--- a/templates/parts/info/_main_infos.html.twig
+++ b/templates/parts/info/_main_infos.html.twig
@@ -1,74 +1,131 @@
{% import "helper.twig" as helper %}
-
-
- {% include "parts/info/_picture.html.twig" %}
-
-
-
- {% if part.manufacturer %}
- {% if part.manufacturer.id is not null %}
- {{ part.manufacturer.name}}
+{% if part.manufacturer or part.manufacturerProductUrl or part.manufacturerProductNumber %}
+
+ {{ part.name }}
+ {# You need edit permission to use the edit button #}
+ {% if timeTravel is not null %}
+
+ {% elseif is_granted('edit', part) %}
+
+ {% endif %}
+
+
+{# Slighlty highlight every text in this block over normal text (similar to h5) #}
+
+ {% trans %}part.part_lots.label{% endtrans %}
+
+
+
+
+ {% if not part.amountUnknown %}
+ {# For known instock we can just show the label as normal #}
+ {{ part.amountSum | format_amount(part.partUnit) }}
+ {% else %}
+ {% if part.amountSum == 0.0 %}
+ ?
+ {% else %}
+ ≥{{ part.amountSum | format_amount(part.partUnit) }}
{% endif %}
{% endif %}
- {% if part.manufacturerProductUrl %}
-
- {{ part.manufacturerProductNumber }}
-
- {% else %}
- {{ part.manufacturerProductNumber }}
+ {% if part.expiredAmountSum > 0 %}
+ (+{{ part.expiredAmountSum }})
{% endif %}
-
-
{{ part.name }}
- {# You need edit permission to use the edit button #}
- {% if timeTravel is not null %}
-
- {% elseif is_granted('edit', part) %}
-
- {% endif %}
-