Merge branch 'master' into settings-bundle

This commit is contained in:
Jan Böhmer 2025-01-17 22:06:18 +01:00
commit 8750573724
191 changed files with 27745 additions and 12133 deletions

View file

@ -39,8 +39,8 @@ if [ -d /var/www/html/var/db ]; then
fi fi
fi fi
# Start PHP-FPM # Start PHP-FPM (the PHP_VERSION is replaced by the configured version in the Dockerfile)
service php8.1-fpm start service phpPHP_VERSION-fpm start
# first arg is `-f` or `--some-option` (taken from https://github.com/docker-library/php/blob/master/8.2/bullseye/apache/docker-php-entrypoint) # 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 if [ "${1#-}" != "$1" ]; then

View file

@ -43,6 +43,7 @@
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_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_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_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 EDA_KICAD_CATEGORY_DEPTH PassEnv EDA_KICAD_CATEGORY_DEPTH
# For most configuration files from conf-available/, which are # For most configuration files from conf-available/, which are

34
.env
View file

@ -146,6 +146,40 @@ PROVIDER_LCSC_ENABLED=0
# The currency to get prices in (e.g. EUR, USD, etc.) # The currency to get prices in (e.g. EUR, USD, etc.)
PROVIDER_LCSC_CURRENCY=EUR 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
################################################################################## ##################################################################################
# EDA integration related settings # EDA integration related settings
################################################################################## ##################################################################################

0
.env.dev Normal file
View file

View file

@ -18,7 +18,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
php-versions: [ '8.1', '8.2', '8.3' ] php-versions: [ '8.1', '8.2', '8.3', '8.4' ]
db-type: [ 'mysql', 'sqlite', 'postgres' ] db-type: [ 'mysql', 'sqlite', 'postgres' ]
env: env:
@ -126,7 +126,7 @@ jobs:
run: ./bin/phpunit --coverage-clover=coverage.xml run: ./bin/phpunit --coverage-clover=coverage.xml
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v4 uses: codecov/codecov-action@v5
with: with:
env_vars: PHP_VERSION,DB_TYPE env_vars: PHP_VERSION,DB_TYPE
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}

View file

@ -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 # Install needed dependencies for PHP build
#RUN apt-get update && apt-get install -y pkg-config curl libcurl4-openssl-dev libicu-dev \ #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 \ # 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/* # && 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 mariadb-client postgresql-client \ 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 \ && 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' \ && 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 update && apt-get upgrade -y \
&& apt-get install -y apache2 php8.1 php8.1-fpm 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 php8.1-pgsql gpg sudo \ && apt-get install -y \
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*; apache2 \
php${PHP_VERSION} \
ENV APACHE_CONFDIR /etc/apache2 php${PHP_VERSION}-fpm \
ENV APACHE_ENVVARS $APACHE_CONFDIR/envvars 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 # 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) # Configure apache 2 (taken from https://github.com/docker-library/php/blob/master/8.2/bullseye/apache/Dockerfile)
# generically convert lines like # 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=...") # so that they can be overridden at runtime ("-e APACHE_RUN_USER=...")
RUN sed -ri 's/^export ([^=]+)=(.*)$/: ${\1:=\2}\nexport \1/' "$APACHE_ENVVARS"; \ RUN sed -ri 's/^export ([^=]+)=(.*)$/: ${\1:=\2}\nexport \1/' "$APACHE_ENVVARS"; \
set -eux; . "$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 # logs should go to stdout / stderr
ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log"; \ ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log"; \
@ -36,82 +76,87 @@ RUN sed -ri 's/^export ([^=]+)=(.*)$/: ${\1:=\2}\nexport \1/' "$APACHE_ENVVARS"
ln -sfT /dev/stdout "$APACHE_LOG_DIR/other_vhosts_access.log"; \ ln -sfT /dev/stdout "$APACHE_LOG_DIR/other_vhosts_access.log"; \
chown -R --no-dereference "$APACHE_RUN_USER:$APACHE_RUN_GROUP" "$APACHE_LOG_DIR"; chown -R --no-dereference "$APACHE_RUN_USER:$APACHE_RUN_GROUP" "$APACHE_LOG_DIR";
# Enable php-fpm # ---
RUN a2enmod proxy_fcgi setenvif && a2enconf php8.1-fpm
FROM scratch AS apache-config
ARG PHP_VERSION
# Configure php-fpm to log to stdout of the container (stdout of PID 1) # 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 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 # We also disable the clear_env option to allow the use of environment variables in php-fpm
RUN { \ COPY <<EOF /etc/php/${PHP_VERSION}/fpm/pool.d/zz-docker.conf
echo '[global]'; \ [global]
echo 'error_log = /proc/1/fd/1'; \ error_log = /proc/1/fd/1
echo; \
echo '[www]'; \ [www]
echo 'access.log = /proc/1/fd/1'; \ access.log = /proc/1/fd/1
echo 'catch_workers_output = yes'; \ catch_workers_output = yes
echo 'decorate_workers_output = no'; \ decorate_workers_output = no
echo 'clear_env = no'; \ clear_env = no
} | tee "/etc/php/8.1/fpm/pool.d/zz-docker.conf" EOF
# PHP files should be handled by PHP, and should be preferred over any other file type # PHP files should be handled by PHP, and should be preferred over any other file type
RUN { \ COPY <<EOF /etc/apache2/conf-available/docker-php.conf
echo '<FilesMatch \.php$>'; \ <FilesMatch \\.php$>
echo '\tSetHandler application/x-httpd-php'; \ SetHandler application/x-httpd-php
echo '</FilesMatch>'; \ </FilesMatch>
echo; \
echo 'DirectoryIndex disabled'; \ DirectoryIndex disabled
echo 'DirectoryIndex index.php index.html'; \ DirectoryIndex index.php index.html
echo; \
echo '<Directory /var/www/>'; \ <Directory /var/www/>
echo '\tOptions -Indexes'; \ Options -Indexes
echo '\tAllowOverride All'; \ AllowOverride All
echo '</Directory>'; \ </Directory>
} | tee "$APACHE_CONFDIR/conf-available/docker-php.conf" \ EOF
&& a2enconf docker-php
# Enable opcache and configure it recommended for symfony (see https://symfony.com/doc/current/performance.html) # Enable opcache and configure it recommended for symfony (see https://symfony.com/doc/current/performance.html)
RUN \ COPY <<EOF /etc/php/${PHP_VERSION}/fpm/conf.d/symfony-recommended.ini
{ \ opcache.memory_consumption=256
echo 'opcache.memory_consumption=256'; \ opcache.max_accelerated_files=20000
echo 'opcache.max_accelerated_files=20000'; \ opcache.validate_timestamp=0
echo 'opcache.validate_timestamp=0'; \ # Configure Realpath cache for performance
# Configure Realpath cache for performance realpath_cache_size=4096K
echo 'realpath_cache_size=4096K'; \ realpath_cache_ttl=600
echo 'realpath_cache_ttl=600'; \ EOF
} > /etc/php/8.1/fpm/conf.d/symfony-recommended.ini
# Increase upload limit and enable preloading # Increase upload limit and enable preloading
RUN \ COPY <<EOF /etc/php/${PHP_VERSION}/fpm/conf.d/partdb.ini
{ \ upload_max_filesize=256M
echo 'upload_max_filesize=256M'; \ post_max_size=300M
echo 'post_max_size=300M'; \ opcache.preload_user=www-data
echo 'opcache.preload_user=www-data'; \ opcache.preload=/var/www/html/config/preload.php
echo 'opcache.preload=/var/www/html/config/preload.php'; \ log_limit=8096
} > /etc/php/8.1/fpm/conf.d/partdb.ini EOF
# Install node and yarn COPY ./.docker/symfony.conf /etc/apache2/sites-available/symfony.conf
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
RUN curl -sL https://deb.nodesource.com/setup_18.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
FROM base
ARG PHP_VERSION
# Set working dir # Set working dir
WORKDIR /var/www/html WORKDIR /var/www/html
COPY --from=apache-config / /
COPY --chown=www-data:www-data . . COPY --chown=www-data:www-data . .
# Setup apache2 # Setup apache2
RUN a2dissite 000-default.conf RUN a2dissite 000-default.conf && \
COPY ./.docker/symfony.conf /etc/apache2/sites-available/symfony.conf a2ensite symfony.conf && \
RUN a2ensite symfony.conf # Enable php-fpm
RUN a2enmod rewrite a2enmod proxy_fcgi setenvif && \
a2enconf php${PHP_VERSION}-fpm && \
a2enconf docker-php && \
a2enmod rewrite
# Install composer and yarn dependencies for Part-DB # Install composer and yarn dependencies for Part-DB
USER www-data USER www-data
RUN composer install -a --no-dev && composer clear-cache RUN composer install -a --no-dev && \
RUN yarn install --network-timeout 600000 && yarn build && yarn cache clean && rm -rf node_modules/ composer clear-cache
RUN yarn install --network-timeout 600000 && \
yarn build && \
yarn cache clean && \
rm -rf node_modules/
# Use docker env to output logs to stdout # Use docker env to output logs to stdout
ENV APP_ENV=docker ENV APP_ENV=docker
@ -119,10 +164,12 @@ ENV DATABASE_URL="sqlite:///%kernel.project_dir%/uploads/app.db"
USER root USER root
# Copy entrypoint to /usr/local/bin and make it executable # Replace the php version placeholder in the entry point, with our php version
RUN cp ./.docker/partdb-entrypoint.sh /usr/local/bin/partdb-entrypoint.sh && chmod +x /usr/local/bin/partdb-entrypoint.sh RUN sed -i "s/PHP_VERSION/${PHP_VERSION}/g" ./.docker/partdb-entrypoint.sh
# Copy apache2-foreground to /usr/local/bin and make it executable
RUN cp ./.docker/apache2-foreground /usr/local/bin/apache2-foreground && chmod +x /usr/local/bin/apache2-foreground # Copy entrypoint and apache2-foreground to /usr/local/bin and make it executable
RUN install ./.docker/partdb-entrypoint.sh /usr/local/bin && \
install ./.docker/apache2-foreground /usr/local/bin
ENTRYPOINT ["partdb-entrypoint.sh"] ENTRYPOINT ["partdb-entrypoint.sh"]
CMD ["apache2-foreground"] CMD ["apache2-foreground"]
@ -130,4 +177,4 @@ CMD ["apache2-foreground"]
STOPSIGNAL SIGWINCH STOPSIGNAL SIGWINCH
EXPOSE 80 EXPOSE 80
VOLUME ["/var/www/html/uploads", "/var/www/html/public/media"] VOLUME ["/var/www/html/uploads", "/var/www/html/public/media"]

View file

@ -1,11 +1,25 @@
FROM dunglas/frankenphp:1-php8.3 AS frankenphp_upstream FROM dunglas/frankenphp:1-php8.3 AS frankenphp_upstream
RUN apt-get update && apt-get -y install curl zip mariadb-client postgresql-client file acl git gettext ca-certificates gnupg \ RUN apt-get update && apt-get -y install \
curl \
ca-certificates \
mariadb-client \
postgresql-client \
file \
acl \
git \
gettext \
gnupg \
zip \
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*; && apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*;
# Create workdir and set permissions if directory does not exists # Install node and yarn
RUN mkdir -p /app RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
WORKDIR /app 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 PHP # Install PHP
RUN set -eux; \ RUN set -eux; \
@ -33,15 +47,13 @@ ENV FRANKENPHP_CONFIG="import worker.Caddyfile"
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# Install node and yarn
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
RUN 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 # Install composer
ENV COMPOSER_ALLOW_SUPERUSER=1 ENV COMPOSER_ALLOW_SUPERUSER=1
#COPY --from=composer:latest /usr/bin/composer /usr/bin/composer #COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Create workdir and set permissions if directory does not exists
WORKDIR /app
# prevent the reinstallation of vendors at every changes in the source code # prevent the reinstallation of vendors at every changes in the source code
COPY --link composer.* symfony.* ./ COPY --link composer.* symfony.* ./
RUN set -eux; \ RUN set -eux; \
@ -58,7 +70,10 @@ RUN set -eux; \
composer run-script --no-dev post-install-cmd; \ composer run-script --no-dev post-install-cmd; \
chmod +x bin/console; sync; chmod +x bin/console; sync;
RUN yarn install --network-timeout 600000 && yarn build && yarn cache clean && rm -rf node_modules/ RUN yarn install --network-timeout 600000 && \
yarn build && \
yarn cache clean && \
rm -rf node_modules/
# Use docker env to output logs to stdout # Use docker env to output logs to stdout
ENV APP_ENV=docker ENV APP_ENV=docker
@ -83,4 +98,4 @@ ENV XDG_DATA_HOME /data
EXPOSE 80 EXPOSE 80
EXPOSE 443 EXPOSE 443
EXPOSE 443/udp EXPOSE 443/udp
EXPOSE 2019 EXPOSE 2019

View file

@ -1 +1 @@
1.13.2 1.15.1

View file

@ -53,6 +53,7 @@ export default class extends Controller {
const config = { const config = {
language: language, language: language,
licenseKey: "GPL",
} }
const watchdog = new EditorWatchdog(); const watchdog = new EditorWatchdog();

View file

@ -186,5 +186,15 @@ export default class extends Controller {
]; ];
}, },
}); });
//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);
});
}
} }
} }

View file

@ -24,7 +24,6 @@ import {Controller} from "@hotwired/stimulus";
import {trans, ENTITY_SELECT_GROUP_NEW_NOT_ADDED_TO_DB} from '../../translator.js' import {trans, ENTITY_SELECT_GROUP_NEW_NOT_ADDED_TO_DB} from '../../translator.js'
export default class extends Controller { export default class extends Controller {
_tomSelect; _tomSelect;
@ -58,7 +57,21 @@ export default class extends Controller {
render: { render: {
item: this.renderItem.bind(this), item: this.renderItem.bind(this),
option: this.renderOption.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 '<div class="create"><i class="fa-solid fa-plus fa-fw"></i>&nbsp;<strong>' + escape(data.input) + '</strong>&hellip;&nbsp;' + return '<div class="create"><i class="fa-solid fa-plus fa-fw"></i>&nbsp;<strong>' + escape(data.input) + '</strong>&hellip;&nbsp;' +
'<small class="text-muted float-end">(' + addHint +')</small>' + '<small class="text-muted float-end">(' + addHint +')</small>' +
'</div>'; '</div>';
@ -76,6 +89,22 @@ export default class extends Controller {
} }
createItem(input, callback) { 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({ callback({
//$%$ is a special value prefix, that is used to identify items, that are not yet in the DB //$%$ is a special value prefix, that is used to identify items, that are not yet in the DB
value: '$%$' + input, value: '$%$' + input,

View file

@ -20,7 +20,7 @@
import {Controller} from "@hotwired/stimulus"; import {Controller} from "@hotwired/stimulus";
//import * as ZXing from "@zxing/library"; //import * as ZXing from "@zxing/library";
import {Html5QrcodeScanner, Html5Qrcode} from "html5-qrcode"; import {Html5QrcodeScanner, Html5Qrcode} from "@part-db/html5-qrcode";
/* stimulusFetch: 'lazy' */ /* stimulusFetch: 'lazy' */
export default class extends Controller { export default class extends Controller {
@ -50,7 +50,7 @@ export default class extends Controller {
}); });
this._scanner = new Html5QrcodeScanner(this.element.id, { this._scanner = new Html5QrcodeScanner(this.element.id, {
fps: 2, fps: 10,
qrbox: qrboxFunction, qrbox: qrboxFunction,
experimentalFeatures: { experimentalFeatures: {
//This option improves reading quality on android chrome //This option improves reading quality on android chrome
@ -61,6 +61,11 @@ export default class extends Controller {
this._scanner.render(this.onScanSuccess.bind(this)); this._scanner.render(this.onScanSuccess.bind(this));
} }
disconnect() {
this._scanner.pause();
this._scanner.clear();
}
onScanSuccess(decodedText, decodedResult) { onScanSuccess(decodedText, decodedResult) {
//Put our decoded Text into the input box //Put our decoded Text into the input box
document.getElementById('scan_dialog_input').value = decodedText; document.getElementById('scan_dialog_input').value = decodedText;

View file

@ -51,7 +51,6 @@
.part-table-image { .part-table-image {
max-height: 40px; max-height: 40px;
object-fit: contain; object-fit: contain;
width: 100%;
} }
.part-info-image { .part-info-image {
@ -61,4 +60,4 @@
.object-fit-cover { .object-fit-cover {
object-fit: cover; object-fit: cover;
} }

View file

@ -108,8 +108,8 @@ body {
.back-to-top { .back-to-top {
cursor: pointer; cursor: pointer;
position: fixed; position: fixed;
bottom: 20px; bottom: 60px;
right: 20px; right: 40px;
display:none; display:none;
z-index: 1030; z-index: 1030;
} }

View file

@ -63,10 +63,6 @@ table.dataTable > tbody > tr.selected > td > a {
margin-block-end: 0; margin-block-end: 0;
} }
.card-footer-table {
padding-top: 0;
}
table.dataTable { table.dataTable {
margin-top: 0 !important; margin-top: 0 !important;
} }

View file

@ -24,9 +24,8 @@
/** Should be the same settings, as in label_style.css */ /** Should be the same settings, as in label_style.css */
.ck-html-label .ck-content { .ck-html-label .ck-content {
font-family: "DejaVu Sans Mono", monospace; font-family: "DejaVu Sans Mono", monospace;
font-size: 12px; font-size: 12pt;
line-height: 1.0; line-height: 1.0;
font-size-adjust: 1.5;
} }
.ck-html-label .ck-content p { .ck-html-label .ck-content p {

View file

@ -44,4 +44,18 @@ import "./register_events";
import "./tristate_checkboxes"; import "./tristate_checkboxes";
//Define jquery globally //Define jquery globally
window.$ = window.jQuery = require("jquery") window.$ = window.jQuery = require("jquery");
//Use the local WASM file for the ZXing library
import {
setZXingModuleOverrides,
} from "barcode-detector/pure";
import wasmFile from "../../node_modules/zxing-wasm/dist/reader/zxing_reader.wasm";
setZXingModuleOverrides({
locateFile: (path, prefix) => {
if (path.endsWith(".wasm")) {
return wasmFile;
}
return prefix + path;
},
});

View file

@ -21,6 +21,7 @@
import {Dropdown} from "bootstrap"; import {Dropdown} from "bootstrap";
import ClipboardJS from "clipboard"; import ClipboardJS from "clipboard";
import {Modal} from "bootstrap";
class RegisterEventHelper { class RegisterEventHelper {
constructor() { constructor() {
@ -31,9 +32,11 @@ class RegisterEventHelper {
//Initialize ClipboardJS //Initialize ClipboardJS
this.registerLoadHandler(() => { this.registerLoadHandler(() => {
new ClipboardJS('.btn'); new ClipboardJS('.btn');
}) });
this.registerModalDropRemovalOnFormSubmit(); this.registerModalDropRemovalOnFormSubmit();
} }
registerModalDropRemovalOnFormSubmit() { registerModalDropRemovalOnFormSubmit() {
@ -43,6 +46,15 @@ class RegisterEventHelper {
if (back_drop) { if (back_drop) {
back_drop.remove(); back_drop.remove();
} }
//Remove scroll-lock if it is still active
if (document.body.classList.contains('modal-open')) {
document.body.classList.remove('modal-open');
//Remove the padding-right and overflow:hidden from the body
document.body.style.paddingRight = '';
document.body.style.overflow = '';
}
}); });
} }

View file

@ -4,6 +4,10 @@
use App\Kernel; use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_dir(dirname(__DIR__).'/vendor')) {
throw new LogicException('Dependencies are missing. Try running "composer install".');
}
//Increase xdebug.max_nesting_level to 1000 if required (see issue #411) //Increase xdebug.max_nesting_level to 1000 if required (see issue #411)
//Check if xdebug extension is active, and xdebug.max_nesting_level is set to 256 or lower //Check if xdebug extension is active, and xdebug.max_nesting_level is set to 256 or lower
if (extension_loaded('xdebug') && ((int) ini_get('xdebug.max_nesting_level')) <= 256) { if (extension_loaded('xdebug') && ((int) ini_get('xdebug.max_nesting_level')) <= 256) {

View file

@ -1,4 +1,5 @@
{ {
"name": "part-db/part-db-server",
"type": "project", "type": "project",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"require": { "require": {
@ -16,7 +17,7 @@
"brick/math": "0.12.1 as 0.11.0", "brick/math": "0.12.1 as 0.11.0",
"composer/ca-bundle": "^1.3", "composer/ca-bundle": "^1.3",
"composer/package-versions-deprecated": "^1.11.99.5", "composer/package-versions-deprecated": "^1.11.99.5",
"doctrine/data-fixtures": "^1.6.6", "doctrine/data-fixtures": "^2.0.0",
"doctrine/dbal": "^4.0.0", "doctrine/dbal": "^4.0.0",
"doctrine/doctrine-bundle": "^2.0", "doctrine/doctrine-bundle": "^2.0",
"doctrine/doctrine-migrations-bundle": "^3.0", "doctrine/doctrine-migrations-bundle": "^3.0",
@ -40,13 +41,9 @@
"nelmio/cors-bundle": "^2.3", "nelmio/cors-bundle": "^2.3",
"nelmio/security-bundle": "^3.0", "nelmio/security-bundle": "^3.0",
"nyholm/psr7": "^1.1", "nyholm/psr7": "^1.1",
"ocramius/proxy-manager": "2.2.*", "omines/datatables-bundle": "^0.9.1",
"omines/datatables-bundle": "^0.8.0",
"paragonie/sodium_compat": "^1.21", "paragonie/sodium_compat": "^1.21",
"part-db/label-fonts": "^1.0", "part-db/label-fonts": "^1.0",
"php-translation/symfony-bundle": "^0.14.0",
"phpdocumentor/reflection-docblock": "^5.2",
"phpstan/phpdoc-parser": "^1.23",
"runtime/frankenphp-symfony": "^0.2.0", "runtime/frankenphp-symfony": "^0.2.0",
"s9e/text-formatter": "^2.1", "s9e/text-formatter": "^2.1",
"scheb/2fa-backup-code": "^6.8.0", "scheb/2fa-backup-code": "^6.8.0",
@ -71,7 +68,6 @@
"symfony/process": "6.4.*", "symfony/process": "6.4.*",
"symfony/property-access": "6.4.*", "symfony/property-access": "6.4.*",
"symfony/property-info": "6.4.*", "symfony/property-info": "6.4.*",
"symfony/proxy-manager-bridge": "6.4.*",
"symfony/rate-limiter": "6.4.*", "symfony/rate-limiter": "6.4.*",
"symfony/runtime": "6.4.*", "symfony/runtime": "6.4.*",
"symfony/security-bundle": "6.4.*", "symfony/security-bundle": "6.4.*",
@ -93,20 +89,20 @@
"twig/intl-extra": "^3.8", "twig/intl-extra": "^3.8",
"twig/markdown-extra": "^3.8", "twig/markdown-extra": "^3.8",
"twig/string-extra": "^3.8", "twig/string-extra": "^3.8",
"web-auth/webauthn-symfony-bundle": "^4.0.0", "web-auth/webauthn-symfony-bundle": "^4.0.0"
"webmozart/assert": "^1.4"
}, },
"require-dev": { "require-dev": {
"dama/doctrine-test-bundle": "^v8.0.0", "dama/doctrine-test-bundle": "^v8.0.0",
"doctrine/doctrine-fixtures-bundle": "^3.2", "doctrine/doctrine-fixtures-bundle": "^4.0.0",
"ekino/phpstan-banned-code": "^v1.0.0", "ekino/phpstan-banned-code": "^v3.0.0",
"jbtronics/translation-editor-bundle": "^1.0",
"phpstan/extension-installer": "^1.0", "phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "^1.4.7", "phpstan/phpstan": "^2.0.4",
"phpstan/phpstan-doctrine": "^1.2.11", "phpstan/phpstan-doctrine": "^2.0.1",
"phpstan/phpstan-strict-rules": "^1.5", "phpstan/phpstan-strict-rules": "^2.0.1",
"phpstan/phpstan-symfony": "^1.1.7", "phpstan/phpstan-symfony": "^2.0.0",
"phpunit/phpunit": "^9.5", "phpunit/phpunit": "^9.5",
"rector/rector": "^1.1.1", "rector/rector": "^2.0.4",
"roave/security-advisories": "dev-latest", "roave/security-advisories": "dev-latest",
"symfony/browser-kit": "6.4.*", "symfony/browser-kit": "6.4.*",
"symfony/css-selector": "6.4.*", "symfony/css-selector": "6.4.*",

3210
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,6 @@ return [
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true], DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
Gregwar\CaptchaBundle\GregwarCaptchaBundle::class => ['all' => true], Gregwar\CaptchaBundle\GregwarCaptchaBundle::class => ['all' => true],
Translation\Bundle\TranslationBundle::class => ['all' => true],
Florianv\SwapBundle\FlorianvSwapBundle::class => ['all' => true], Florianv\SwapBundle\FlorianvSwapBundle::class => ['all' => true],
Nelmio\SecurityBundle\NelmioSecurityBundle::class => ['all' => true], Nelmio\SecurityBundle\NelmioSecurityBundle::class => ['all' => true],
Symfony\UX\Turbo\TurboBundle::class => ['all' => true], Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
@ -33,4 +32,5 @@ return [
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
Jbtronics\SettingsBundle\JbtronicsSettingsBundle::class => ['all' => true], Jbtronics\SettingsBundle\JbtronicsSettingsBundle::class => ['all' => true],
Jbtronics\TranslationEditorBundle\JbtronicsTranslationEditorBundle::class => ['dev' => true],
]; ];

View file

@ -9,14 +9,13 @@ datatables:
# Set options, as documented at https://datatables.net/reference/option/ # Set options, as documented at https://datatables.net/reference/option/
options: options:
lengthMenu : [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]] lengthMenu : [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]]
pageLength: 50 # Set to -1 to disable pagination (i.e. show all rows) by default pageLength: '%partdb.table.default_page_size%' # Set to -1 to disable pagination (i.e. show all rows) by default
#dom: "<'row' <'col-sm-12' tr>><'row' <'col-sm-6'l><'col-sm-6 text-right'pif>>" dom: " <'row' <'col mb-2 input-group flex-nowrap' B l > <'col-auto mb-2' < p >>>
dom: " <'row'<'col mb-2 input-group' B l> <'col mb-2' <'pull-end' p>>> <'card'
<'card' rt
rt <'card-footer card-footer-table text-muted' i >
<'card-footer card-footer-table text-muted' i > >
> <'row' <'col mt-2 input-group flex-nowrap' B l > <'col-auto mt-2' < p >>>"
<'row'<'col mt-2 input-group' B l> <'col mt-2' <'pull-right' p>>>"
pagingType: 'simple_numbers' pagingType: 'simple_numbers'
searching: true searching: true
stateSave: true stateSave: true

View file

@ -1,5 +0,0 @@
translation:
symfony_profiler:
enabled: true
webui:
enabled: true

View file

@ -57,6 +57,7 @@ doctrine:
field2: App\Doctrine\Functions\Field2 field2: App\Doctrine\Functions\Field2
natsort: App\Doctrine\Functions\Natsort natsort: App\Doctrine\Functions\Natsort
array_position: App\Doctrine\Functions\ArrayPosition array_position: App\Doctrine\Functions\ArrayPosition
ilike: App\Doctrine\Functions\ILike
when@test: when@test:
doctrine: doctrine:

View file

@ -50,7 +50,6 @@ when@prod:
type: stream type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log" path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug level: debug
formatter: monolog.formatter.json
console: console:
type: console type: console
process_psr_3_messages: false process_psr_3_messages: false
@ -74,7 +73,6 @@ when@docker:
type: stream type: stream
path: "php://stderr" path: "php://stderr"
level: debug level: debug
formatter: monolog.formatter.json
console: console:
type: console type: console
process_psr_3_messages: false process_psr_3_messages: false

View file

@ -51,12 +51,16 @@ nelmio_security:
img-src: img-src:
- '*' - '*'
- 'data:' - 'data:'
# Required for be able to load pictures in the QR code scanner
- 'blob:'
style-src: style-src:
- 'self' - 'self'
- 'unsafe-inline' - 'unsafe-inline'
- 'data:' - 'data:'
script-src: script-src:
- 'self' - 'self'
# Required for loading the Wasm for the barcode scanner:
- 'wasm-unsafe-eval'
object-src: object-src:
- 'self' - 'self'
- 'data:' - 'data:'

View file

@ -1,11 +0,0 @@
translation:
locales: ["en", "de"]
edit_in_place:
enabled: false
config_name: app
configs:
app:
dirs: ["%kernel.project_dir%/templates", "%kernel.project_dir%/src"]
output_dir: "%kernel.project_dir%/translations"
excluded_names: ["*TestCase.php", "*Test.php"]
excluded_dirs: [cache, data, logs]

View file

@ -8,7 +8,7 @@ parameters:
# This is used as workaround for places where we can not access the settings directly (like the 2FA application names) # This is used as workaround for places where we can not access the settings directly (like the 2FA application names)
partdb.title: '%env(string:settings:customization:instanceName)%' # The title shown inside of Part-DB (e.g. in the navbar and on homepage) partdb.title: '%env(string:settings:customization:instanceName)%' # The title shown inside of Part-DB (e.g. in the navbar and on homepage)
partdb.locale_menu: ['en', 'de', 'it', 'fr', 'ru', 'ja', 'cs', 'da', 'zh'] # The languages that are shown in user drop down menu partdb.locale_menu: ['en', 'de', 'it', 'fr', 'ru', 'ja', 'cs', 'da', 'zh', 'pl'] # The languages that are shown in user drop down menu
partdb.default_uri: '%env(string:DEFAULT_URI)%' # The default URI to use for the Part-DB instance (e.g. https://part-db.example.com/). This is used for generating links in emails partdb.default_uri: '%env(string:DEFAULT_URI)%' # The default URI to use for the Part-DB instance (e.g. https://part-db.example.com/). This is used for generating links in emails

View file

@ -1,6 +0,0 @@
_translation_webui:
resource: '@TranslationBundle/Resources/config/routing_webui.yaml'
prefix: /admin
_translation_profiler:
resource: '@TranslationBundle/Resources/config/routing_symfony_profiler.yaml'

View file

@ -0,0 +1,3 @@
when@dev:
translation_editor:
resource: '@JbtronicsTranslationEditorBundle/config/routes.php'

View file

@ -1,3 +0,0 @@
_translation_edit_in_place:
resource: '@TranslationBundle/Resources/config/routing_edit_in_place.yaml'
prefix: /admin

View file

@ -216,6 +216,16 @@ services:
$onlyAuthorizedSellers: '%env(bool:PROVIDER_OCTOPART_ONLY_AUTHORIZED_SELLERS)%' $onlyAuthorizedSellers: '%env(bool:PROVIDER_OCTOPART_ONLY_AUTHORIZED_SELLERS)%'
App\Services\InfoProviderSystem\Providers\OEMSecretsProvider:
arguments:
$api_key: '%env(string:PROVIDER_OEMSECRETS_KEY)%'
$country_code: '%env(string:PROVIDER_OEMSECRETS_COUNTRY_CODE)%'
$currency: '%env(PROVIDER_OEMSECRETS_CURRENCY)%'
$zero_price: '%env(PROVIDER_OEMSECRETS_ZERO_PRICE)%'
$set_param: '%env(PROVIDER_OEMSECRETS_SET_PARAM)%'
$sort_criteria: '%env(PROVIDER_OEMSECRETS_SORT_CRITERIA)%'
#################################################################################################################### ####################################################################################################################
# API system # API system
#################################################################################################################### ####################################################################################################################
@ -292,4 +302,4 @@ when@test:
arguments: arguments:
- '@doctrine.fixtures.loader' - '@doctrine.fixtures.loader'
- '@doctrine' - '@doctrine'
- { default: '@App\Doctrine\Purger\DoNotUsePurgerFactory' } - { default: '@App\Doctrine\Purger\DoNotUsePurgerFactory' }

View file

@ -23,7 +23,7 @@ each other so that it does not matter which one of your 1000 things of Part you
A part entity has many fields, which can be used to describe it better. Most of the fields are optional: A part entity has many fields, which can be used to describe it better. Most of the fields are optional:
* **Name** (Required): The name of the part or how you want to call it. This could be a manufacturer-provided name, or a * **Name** (Required): The name of the part or how you want to call it. This could be a manufacturer-provided name, or a
name you thought of yourself. The name have to be unique in a single category. name you thought of yourself. Each name needs to be unique and must exist in a single category.
* **Description**: A short (single-line) description of what this part is/does. For longer information, you should use * **Description**: A short (single-line) description of what this part is/does. For longer information, you should use
the comment field or the specifications the comment field or the specifications
* **Category** (Required): The category (see there) to which this part belongs to. * **Category** (Required): The category (see there) to which this part belongs to.
@ -239,4 +239,4 @@ replaced with data for the actual thing.
You do not have to define a label profile to generate labels (you can just set the settings on the fly in the label You do not have to define a label profile to generate labels (you can just set the settings on the fly in the label
dialog), however, if you want to generate many labels, it is recommended to save the settings as a label profile, to save dialog), however, if you want to generate many labels, it is recommended to save the settings as a label profile, to save
it for later usage. This ensures that all generated labels look the same. it for later usage. This ensures that all generated labels look the same.

View file

@ -32,11 +32,16 @@ options listed, see `.env` file for the full list of possible env variables.
### General options ### General options
* `DATABASE_URL`: Configures the database which Part-DB uses. For mysql use a string in the form * `DATABASE_URL`: Configures the database which Part-DB uses:
of `mysql://<USERNAME>:<PASSWORD>@<HOST>:<PORT>/<TABLE_NAME>` here * For MySQL (or MariaDB) use a string in the form of `mysql://<USERNAME>:<PASSWORD>@<HOST>:<PORT>/<TABLE_NAME>` here
(e.g. `DATABASE_URL=mysql://user:password@127.0.0.1:3306/part-db`). For SQLite use the following format to specify the (e.g. `DATABASE_URL=mysql://user:password@127.0.0.1:3306/part-db`).
* For SQLite use the following format to specify the
absolute path where it should be located `sqlite:///path/part/app.db`. You can use `%kernel.project_dir%` as absolute path where it should be located `sqlite:///path/part/app.db`. You can use `%kernel.project_dir%` as
placeholder for the Part-DB root folder (e.g. `sqlite:///%kernel.project_dir%/var/app.db`) placeholder for the Part-DB root folder (e.g. `sqlite:///%kernel.project_dir%/var/app.db`)
* For Postgresql use a string in the form of `DATABASE_URL=postgresql://user:password@127.0.0.1:5432/part-db?serverVersion=x.y`.
Please note that **`serverVersion=x.y`** variable is required due to dependency of Symfony framework.
* `DATABASE_MYSQL_USE_SSL_CA`: If this value is set to `1` or `true` and a MySQL connection is used, then the connection * `DATABASE_MYSQL_USE_SSL_CA`: If this value is set to `1` or `true` and a MySQL connection is used, then the connection
is encrypted by SSL/TLS and the server certificate is verified against the system CA certificates or the CA certificate is encrypted by SSL/TLS and the server certificate is verified against the system CA certificates or the CA certificate
bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept all certificates. bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept all certificates.
@ -86,6 +91,10 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
* `datastructure_create`: Creation of a new datastructure (e.g. category, manufacturer, ...) * `datastructure_create`: Creation of a new datastructure (e.g. category, manufacturer, ...)
* `CHECK_FOR_UPDATES` (default `1`): Set this to 0, if you do not want Part-DB to connect to GitHub to check for new * `CHECK_FOR_UPDATES` (default `1`): Set this to 0, if you do not want Part-DB to connect to GitHub to check for new
versions, or if your server can not connect to the internet. versions, or if your server can not connect to the internet.
* `APP_SECRET`: This variable is a configuration parameter used for various security-related purposes,
particularly for securing and protecting various aspects of your application. It's a secret key that is used for
cryptographic operations and security measures (session management, CSRF protection, etc..). Therefore this
value should be handled as confidential data and not shared publicly.
### E-Mail settings ### E-Mail settings
@ -243,4 +252,4 @@ The following options are available:
number of sidebar panels by changing the number of items in this list. number of sidebar panels by changing the number of items in this list.
* `partdb.sidebar.root_node_enable`: Show a root node in the sidebar trees, of which all nodes are children of * `partdb.sidebar.root_node_enable`: Show a root node in the sidebar trees, of which all nodes are children of
* `partdb.sidebar.root_expanded`: Expand the root node in the sidebar trees by default * `partdb.sidebar.root_expanded`: Expand the root node in the sidebar trees by default
* `partdb.available_themes`: The list of available themes a user can choose from. * `partdb.available_themes`: The list of available themes a user can choose from.

View file

@ -6,4 +6,6 @@ has_children: true
--- ---
# Installation # Installation
Below you can find some guides to install Part-DB. Below you can find some guides to install Part-DB.
For the hobbyists without much experience, we recommend the docker installation or direct installation on debian.

View file

@ -158,7 +158,7 @@ services:
container_name: partdb_database container_name: partdb_database
image: mysql:8.0 image: mysql:8.0
restart: unless-stopped restart: unless-stopped
command: --default-authentication-plugin=mysql_native_password command: --default-authentication-plugin=mysql_native_password --log-bin-trust-function-creators=1
environment: environment:
# Change this Password # Change this Password
MYSQL_ROOT_PASSWORD: SECRET_ROOT_PASSWORD MYSQL_ROOT_PASSWORD: SECRET_ROOT_PASSWORD

View file

@ -0,0 +1,42 @@
---
title: Kubernetes / Helm
layout: default
parent: Installation
nav_order: 5
---
# Kubernetes / Helm Charts
If you are using Kubernetes, you can use the [helm charts](https://helm.sh/) provided in this [repository](https://github.com/Part-DB/helm-charts).
## Usage
[Helm](https://helm.sh) must be installed to use the charts. Please refer to
Helm's [documentation](https://helm.sh/docs) to get started.
Once Helm has been set up correctly, add the repo as follows:
`helm repo add part-db https://part-db.github.io/helm-charts`
If you had already added this repo earlier, run `helm repo update` to retrieve
the latest versions of the packages. You can then run `helm search repo
part-db` to see the charts.
To install the part-db chart:
helm install my-part-db part-db/part-db
To uninstall the chart:
helm delete my-part-db
This repository is also available at [ArtifactHUB](https://artifacthub.io/packages/search?repo=part-db).
## Configuration
See the README in the [chart directory](https://github.com/Part-DB/helm-charts/tree/main/charts/part-db) for more
information on the available configuration options.
## Bugreports
If you find issues related to the helm charts, please open an issue in the [helm-charts repository](https://github.com/Part-DB/helm-charts).

View file

@ -0,0 +1,31 @@
---
title: Proxmox VE LXC
layout: default
parent: Installation
nav_order: 6
---
# Proxmox VE LXC
{: .warning }
> The proxmox VE LXC script for Part-DB is developed and maintained by [Proxmox VE Helper-Scripts](https://community-scripts.github.io/ProxmoxVE/)
> and not by the Part-DB developers. Keep in mind that the script is not officially supported by the Part-DB developers.
If you are using Proxmox VE you can use the scripts provided by [Proxmox VE Helper-Scripts community](https://community-scripts.github.io/ProxmoxVE/scripts?id=part-db)
to easily install Part-DB in a LXC container.
## Usage
To create a new LXC container with Part-DB, you can use the following command in the Proxmox VE shell:
```bash
bash -c "$(wget -qLO - https://github.com/community-scripts/ProxmoxVE/raw/main/ct/part-db.sh)"
```
The same command can be used to update an existing Part-DB container.
See the [helper script website](https://community-scripts.github.io/ProxmoxVE/scripts?id=part-db) for more information.
## Bugreports
If you find issues related to the proxmox VE LXC script, please open an issue in the [Proxmox VE Helper-Scripts repository](https://github.com/community-scripts/ProxmoxVE).

View file

@ -107,7 +107,7 @@ The following env configuration options are available:
default: `EUR`). If an offer is only available in a certain currency, default: `EUR`). If an offer is only available in a certain currency,
Part-DB will save the prices in their native currency, and you can use Part-DB currency conversion feature to convert Part-DB will save the prices in their native currency, and you can use Part-DB currency conversion feature to convert
it to your preferred currency. it to your preferred currency.
* `PROVIDER_OCOTPART_COUNTRY`: The country you want to get prices in if available (optional, 2 letter ISO-code, * `PROVIDER_OCTOPART_COUNTRY`: The country you want to get prices in if available (optional, 2 letter ISO-code,
default: `DE`). To get the correct prices, you have to set this and the currency setting to the correct value. default: `DE`). To get the correct prices, you have to set this and the currency setting to the correct value.
* `PROVIDER_OCTOPART_SEARCH_LIMIT`: The maximum number of results to return per search (optional, default: `10`). This * `PROVIDER_OCTOPART_SEARCH_LIMIT`: The maximum number of results to return per search (optional, default: `10`). This
affects how quickly your monthly limit is used up. affects how quickly your monthly limit is used up.
@ -212,6 +212,26 @@ An API key is not required, it is enough to enable the provider using the follow
* `PROVIDER_LCSC_ENABLED`: Set this to `1` to enable the LCSC provider * `PROVIDER_LCSC_ENABLED`: Set this to `1` to enable the LCSC provider
* `PROVIDER_LCSC_CURRENCY`: The currency you want to get prices in (see LCSC webshop for available currencies, default: `EUR`) * `PROVIDER_LCSC_CURRENCY`: The currency you want to get prices in (see LCSC webshop for available currencies, default: `EUR`)
### OEMsecrets
The oemsecrets provider uses the [oemsecrets API](https://www.oemsecrets.com/) to search for parts and getting shopping
information from them. Similar to octopart it aggregates offers from different distributors.
You can apply for a free API key on the [oemsecrets API page](https://www.oemsecrets.com/api/) and put the key you get
in the Part-DB env configuration (see below).
The following env configuration options are available:
* `PROVIDER_OEMSECRETS_KEY`: The API key you got from oemsecrets (mandatory)
* `PROVIDER_OEMSECRETS_COUNTRY_CODE`: The two-letter code of the country you want to get the prices for
* `PROVIDER_OEMSECRETS_CURRENCY`: The currency you want to get prices in (optional, default: `EUR`)
* `PROVIDER_OEMSECRETS_ZERO_PRICE`: If set to `1`, parts with a price of 0 will be included in the search results, otherwise
they will be excluded (optional, default: `0`)
* `PROVIDER_OEMSECRETS_SET_PARAM`: If set to `1`, the provider will try to extract parameters from the part description
* `PROVIDER_OEMSECRETS_SORT_CRITERIA`: The criteria to sort the search results by. 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 set to any other value, no sorting is performed.
### Custom provider ### Custom provider
To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long

View file

@ -9,7 +9,7 @@
"@symfony/stimulus-bridge": "^3.2.0", "@symfony/stimulus-bridge": "^3.2.0",
"@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets", "@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets",
"@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets", "@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets",
"@symfony/webpack-encore": "^4.1.0", "@symfony/webpack-encore": "^5.0.0",
"bootstrap": "^5.1.3", "bootstrap": "^5.1.3",
"core-js": "^3.23.0", "core-js": "^3.23.0",
"intl-messageformat": "^10.2.5", "intl-messageformat": "^10.2.5",
@ -18,7 +18,7 @@
"regenerator-runtime": "^0.13.9", "regenerator-runtime": "^0.13.9",
"webpack": "^5.74.0", "webpack": "^5.74.0",
"webpack-bundle-analyzer": "^4.3.0", "webpack-bundle-analyzer": "^4.3.0",
"webpack-cli": "^4.10.0", "webpack-cli": "^5.1.0",
"webpack-notifier": "^1.15.0" "webpack-notifier": "^1.15.0"
}, },
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
@ -33,50 +33,52 @@
"@algolia/autocomplete-js": "^1.17.0", "@algolia/autocomplete-js": "^1.17.0",
"@algolia/autocomplete-plugin-recent-searches": "^1.17.0", "@algolia/autocomplete-plugin-recent-searches": "^1.17.0",
"@algolia/autocomplete-theme-classic": "^1.17.0", "@algolia/autocomplete-theme-classic": "^1.17.0",
"@ckeditor/ckeditor5-alignment": "^41.0.0", "@ckeditor/ckeditor5-alignment": "^44.0.0",
"@ckeditor/ckeditor5-autoformat": "^41.0.0", "@ckeditor/ckeditor5-autoformat": "^44.0.0",
"@ckeditor/ckeditor5-basic-styles": "^41.0.0", "@ckeditor/ckeditor5-basic-styles": "^44.0.0",
"@ckeditor/ckeditor5-block-quote": "^41.0.0", "@ckeditor/ckeditor5-block-quote": "^44.0.0",
"@ckeditor/ckeditor5-code-block": "^41.0.0", "@ckeditor/ckeditor5-code-block": "^44.0.0",
"@ckeditor/ckeditor5-dev-translations": "^39.1.0", "@ckeditor/ckeditor5-dev-translations": "^43.0.1",
"@ckeditor/ckeditor5-dev-utils": "^39.1.0", "@ckeditor/ckeditor5-dev-utils": "^43.0.1",
"@ckeditor/ckeditor5-editor-classic": "^41.0.0", "@ckeditor/ckeditor5-editor-classic": "^44.0.0",
"@ckeditor/ckeditor5-essentials": "^41.0.0", "@ckeditor/ckeditor5-essentials": "^44.0.0",
"@ckeditor/ckeditor5-find-and-replace": "^41.0.0", "@ckeditor/ckeditor5-find-and-replace": "^44.0.0",
"@ckeditor/ckeditor5-font": "^41.0.0", "@ckeditor/ckeditor5-font": "^44.0.0",
"@ckeditor/ckeditor5-heading": "^41.0.0", "@ckeditor/ckeditor5-heading": "^44.0.0",
"@ckeditor/ckeditor5-highlight": "^41.0.0", "@ckeditor/ckeditor5-highlight": "^44.0.0",
"@ckeditor/ckeditor5-horizontal-line": "^41.0.0", "@ckeditor/ckeditor5-horizontal-line": "^44.0.0",
"@ckeditor/ckeditor5-html-embed": "^41.0.0", "@ckeditor/ckeditor5-html-embed": "^44.0.0",
"@ckeditor/ckeditor5-html-support": "^41.0.0", "@ckeditor/ckeditor5-html-support": "^44.0.0",
"@ckeditor/ckeditor5-image": "^41.0.0", "@ckeditor/ckeditor5-image": "^44.0.0",
"@ckeditor/ckeditor5-indent": "^41.0.0", "@ckeditor/ckeditor5-indent": "^44.0.0",
"@ckeditor/ckeditor5-link": "^41.0.0", "@ckeditor/ckeditor5-link": "^44.0.0",
"@ckeditor/ckeditor5-list": "^41.0.0", "@ckeditor/ckeditor5-list": "^44.0.0",
"@ckeditor/ckeditor5-markdown-gfm": "^41.0.0", "@ckeditor/ckeditor5-markdown-gfm": "^44.0.0",
"@ckeditor/ckeditor5-media-embed": "^41.0.0", "@ckeditor/ckeditor5-media-embed": "^44.0.0",
"@ckeditor/ckeditor5-paragraph": "^41.0.0", "@ckeditor/ckeditor5-paragraph": "^44.0.0",
"@ckeditor/ckeditor5-paste-from-office": "^41.0.0", "@ckeditor/ckeditor5-paste-from-office": "^44.0.0",
"@ckeditor/ckeditor5-remove-format": "^41.0.0", "@ckeditor/ckeditor5-remove-format": "^44.0.0",
"@ckeditor/ckeditor5-source-editing": "^41.0.0", "@ckeditor/ckeditor5-source-editing": "^44.0.0",
"@ckeditor/ckeditor5-special-characters": "^41.0.0", "@ckeditor/ckeditor5-special-characters": "^44.0.0",
"@ckeditor/ckeditor5-table": "^41.0.0", "@ckeditor/ckeditor5-table": "^44.0.0",
"@ckeditor/ckeditor5-theme-lark": "^41.0.0", "@ckeditor/ckeditor5-theme-lark": "^44.0.0",
"@ckeditor/ckeditor5-upload": "^41.0.0", "@ckeditor/ckeditor5-upload": "^44.0.0",
"@ckeditor/ckeditor5-watchdog": "^41.0.0", "@ckeditor/ckeditor5-watchdog": "^44.0.0",
"@ckeditor/ckeditor5-word-count": "^41.0.0", "@ckeditor/ckeditor5-word-count": "^44.0.0",
"@jbtronics/bs-treeview": "^1.0.1", "@jbtronics/bs-treeview": "^1.0.1",
"@part-db/html5-qrcode": "^3.1.0",
"@zxcvbn-ts/core": "^3.0.2", "@zxcvbn-ts/core": "^3.0.2",
"@zxcvbn-ts/language-common": "^3.0.3", "@zxcvbn-ts/language-common": "^3.0.3",
"@zxcvbn-ts/language-de": "^3.0.1", "@zxcvbn-ts/language-de": "^3.0.1",
"@zxcvbn-ts/language-en": "^3.0.1", "@zxcvbn-ts/language-en": "^3.0.1",
"@zxcvbn-ts/language-fr": "^3.0.1", "@zxcvbn-ts/language-fr": "^3.0.1",
"@zxcvbn-ts/language-ja": "^3.0.1", "@zxcvbn-ts/language-ja": "^3.0.1",
"barcode-detector": "^2.3.1",
"bootbox": "^6.0.0", "bootbox": "^6.0.0",
"bootswatch": "^5.1.3", "bootswatch": "^5.1.3",
"bs-custom-file-input": "^1.3.4", "bs-custom-file-input": "^1.3.4",
"clipboard": "^2.0.4", "clipboard": "^2.0.4",
"compression-webpack-plugin": "^10.0.0", "compression-webpack-plugin": "^11.1.0",
"datatables.net": "^2.0.0", "datatables.net": "^2.0.0",
"datatables.net-bs5": "^2.0.0", "datatables.net-bs5": "^2.0.0",
"datatables.net-buttons-bs5": "^3.0.0", "datatables.net-buttons-bs5": "^3.0.0",
@ -86,18 +88,17 @@
"datatables.net-select-bs5": "^2.0.0", "datatables.net-select-bs5": "^2.0.0",
"dompurify": "^3.0.3", "dompurify": "^3.0.3",
"emoji.json": "^15.0.0", "emoji.json": "^15.0.0",
"exports-loader": "^3.0.0", "exports-loader": "^5.0.0",
"html5-qrcode": "^2.2.1",
"json-formatter-js": "^2.3.4", "json-formatter-js": "^2.3.4",
"jszip": "^3.2.0", "jszip": "^3.2.0",
"katex": "^0.16.0", "katex": "^0.16.0",
"marked": "^12.0.0", "marked": "^15.0.4",
"marked-gfm-heading-id": "^3.0.4", "marked-gfm-heading-id": "^4.1.1",
"marked-mangle": "^1.0.1", "marked-mangle": "^1.0.1",
"pdfmake": "^0.2.2", "pdfmake": "^0.2.2",
"stimulus-use": "^0.52.0", "stimulus-use": "^0.52.0",
"tom-select": "^2.1.0", "tom-select": "^2.1.0",
"ts-loader": "^9.2.6", "ts-loader": "^9.2.6",
"typescript": "^4.0.2" "typescript": "^5.7.2"
} }
} }

View file

@ -12,6 +12,7 @@ parameters:
- src/Doctrine/Purger/* - src/Doctrine/Purger/*
- src/DataTables/Adapters/TwoStepORMAdapter.php - src/DataTables/Adapters/TwoStepORMAdapter.php
- src/Form/Fixes/* - src/Form/Fixes/*
- src/Translation/Fixes/*
@ -19,7 +20,7 @@ parameters:
treatPhpDocTypesAsCertain: false treatPhpDocTypesAsCertain: false
symfony: symfony:
container_xml_path: '%rootDir%/../../../var/cache/dev/App_KernelDevDebugContainer.xml' containerXmlPath: '%rootDir%/../../../var/cache/dev/App_KernelDevDebugContainer.xml'
doctrine: doctrine:
objectManagerLoader: tests/object-manager.php objectManagerLoader: tests/object-manager.php
@ -29,11 +30,6 @@ parameters:
checkFunctionNameCase: false checkFunctionNameCase: false
checkAlwaysTrueInstanceof: false
checkAlwaysTrueCheckTypeFunctionCall: false
checkAlwaysTrueStrictComparison: false
reportAlwaysTrueInLastCondition: false
reportMaybesInPropertyPhpDocTypes: false reportMaybesInPropertyPhpDocTypes: false
reportMaybesInMethodSignatures: false reportMaybesInMethodSignatures: false
@ -42,14 +38,14 @@ parameters:
booleansInConditions: false booleansInConditions: false
uselessCast: false uselessCast: false
requireParentConstructorCall: true requireParentConstructorCall: true
disallowedConstructs: false
overwriteVariablesWithLoop: false overwriteVariablesWithLoop: false
closureUsesThis: false closureUsesThis: false
matchingInheritedMethodNames: true matchingInheritedMethodNames: true
numericOperandsInArithmeticOperators: true numericOperandsInArithmeticOperators: true
strictCalls: true
switchConditionsMatchingType: false switchConditionsMatchingType: false
noVariableVariables: false noVariableVariables: false
disallowedEmpty: false
disallowedShortTernary: false
ignoreErrors: ignoreErrors:
# Ignore errors caused by complex mapping with AbstractStructuralDBElement # Ignore errors caused by complex mapping with AbstractStructuralDBElement
@ -61,4 +57,7 @@ parameters:
- '#Part::getParameters\(\) should return .*AbstractParameter#' - '#Part::getParameters\(\) should return .*AbstractParameter#'
# Ignore doctrine type mapping mismatch # Ignore doctrine type mapping mismatch
- '#Property .* type mapping mismatch: property can contain .* but database expects .*#' - '#Property .* type mapping mismatch: property can contain .* but database expects .*#'
# Ignore error of unused WithPermPresetsTrait, as it is used in the migrations which are not analyzed by Phpstan
- '#Trait App\\Migration\\WithPermPresetsTrait is used zero times and is not analysed#'

View file

@ -50,7 +50,7 @@ final class LikeFilter extends AbstractFilter
} }
$parameterName = $queryNameGenerator->generateParameterName($property); // Generate a unique parameter name to avoid collisions with other filters $parameterName = $queryNameGenerator->generateParameterName($property); // Generate a unique parameter name to avoid collisions with other filters
$queryBuilder $queryBuilder
->andWhere(sprintf('o.%s LIKE :%s', $property, $parameterName)) ->andWhere(sprintf('ILIKE(o.%s, :%s) = TRUE', $property, $parameterName))
->setParameter($parameterName, $value); ->setParameter($parameterName, $value);
} }

View file

@ -0,0 +1,102 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\ApiPlatform\Filter;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\PropertyInfo\Type;
/**
* Due to their nature, tags are stored in a single string, separated by commas, which requires some more complex search logic.
* This filter allows to easily search for tags in a part entity.
*/
final class TagFilter extends AbstractFilter
{
protected function filterProperty(
string $property,
$value,
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = []
): void {
// Ignore filter if property is not enabled or mapped
if (
!$this->isPropertyEnabled($property, $resourceClass) ||
!$this->isPropertyMapped($property, $resourceClass)
) {
return;
}
//Escape any %, _ or \ in the tag
$value = addcslashes($value, '%_\\');
$tag_identifier_prefix = $queryNameGenerator->generateParameterName($property);
$expr = $queryBuilder->expr();
$tmp = $expr->orX(
'ILIKE(o.'.$property.', :' . $tag_identifier_prefix . '_1) = TRUE',
'ILIKE(o.'.$property.', :' . $tag_identifier_prefix . '_2) = TRUE',
'ILIKE(o.'.$property.', :' . $tag_identifier_prefix . '_3) = TRUE',
'ILIKE(o.'.$property.', :' . $tag_identifier_prefix . '_4) = TRUE',
);
$queryBuilder->andWhere($tmp);
//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)
$queryBuilder->setParameter($tag_identifier_prefix . '_1', '%,' . $value . ',%');
$queryBuilder->setParameter($tag_identifier_prefix . '_2', '%,' . $value);
$queryBuilder->setParameter($tag_identifier_prefix . '_3', $value . ',%');
$queryBuilder->setParameter($tag_identifier_prefix . '_4', $value);
}
public function getDescription(string $resourceClass): array
{
if (!$this->properties) {
return [];
}
$description = [];
foreach (array_keys($this->properties) as $property) {
$description[(string)$property] = [
'property' => $property,
'type' => Type::BUILTIN_TYPE_STRING,
'required' => false,
'description' => 'Filter for tags of a part',
'openapi' => [
'example' => '',
'allowReserved' => false,// if true, query parameters will be not percent-encoded
'allowEmptyValue' => true,
'explode' => false, // to be true, the type must be Type::BUILTIN_TYPE_ARRAY, ?product=blue,green will be ?product=blue&product=green
],
];
}
return $description;
}
}

View file

@ -79,7 +79,7 @@ class CheckRequirementsCommand extends Command
//Checking 32-bit system //Checking 32-bit system
if (PHP_INT_SIZE === 4) { if (PHP_INT_SIZE === 4) {
$io->warning('You are using a 32-bit system. You will have problems with working with dates after the year 2038, therefore a 64-bit system is recommended.'); $io->warning('You are using a 32-bit system. You will have problems with working with dates after the year 2038, therefore a 64-bit system is recommended.');
} elseif (PHP_INT_SIZE === 8) { } elseif (PHP_INT_SIZE === 8) { //@phpstan-ignore-line //PHP_INT_SIZE is always 4 or 8
if (!$only_issues) { if (!$only_issues) {
$io->success('You are using a 64-bit system.'); $io->success('You are using a 64-bit system.');
} }

View file

@ -79,6 +79,7 @@ class ConvertBBCodeCommand extends Command
/** /**
* Returns a list which entities and which properties need to be checked. * Returns a list which entities and which properties need to be checked.
* @return array<class-string<AbstractNamedDBElement>, string[]>
*/ */
protected function getTargetsLists(): array protected function getTargetsLists(): array
{ {
@ -109,7 +110,6 @@ class ConvertBBCodeCommand extends Command
$class $class
)); ));
//Determine which entities of this type we need to modify //Determine which entities of this type we need to modify
/** @var EntityRepository $repo */
$repo = $this->em->getRepository($class); $repo = $this->em->getRepository($class);
$qb = $repo->createQueryBuilder('e') $qb = $repo->createQueryBuilder('e')
->select('e'); ->select('e');

View file

@ -83,6 +83,19 @@ class SetPasswordCommand extends Command
while (!$success) { while (!$success) {
$pw1 = $io->askHidden('Please enter new password:'); $pw1 = $io->askHidden('Please enter new password:');
if ($pw1 === null) {
$io->error('No password entered! Please try again.');
//If we are in non-interactive mode, we can not ask again
if (!$input->isInteractive()) {
$io->warning('Non-interactive mode detected. No password can be entered that way! If you are using docker exec, please use -it flag.');
return Command::FAILURE;
}
continue;
}
$pw2 = $io->askHidden('Please confirm:'); $pw2 = $io->askHidden('Please confirm:');
if ($pw1 !== $pw2) { if ($pw1 !== $pw2) {
$io->error('The entered password did not match! Please try again.'); $io->error('The entered password did not match! Please try again.');

View file

@ -206,12 +206,15 @@ class UsersPermissionsCommand extends Command
return '<fg=green>Allow</>'; return '<fg=green>Allow</>';
} elseif ($permission_value === false) { } elseif ($permission_value === false) {
return '<fg=red>Disallow</>'; return '<fg=red>Disallow</>';
} elseif ($permission_value === null && !$inherit) { }
// Permission value is null by this point
elseif (!$inherit) {
return '<fg=blue>Inherit</>'; return '<fg=blue>Inherit</>';
} elseif ($permission_value === null && $inherit) { } elseif ($inherit) {
return '<fg=red>Disallow (Inherited)</>'; return '<fg=red>Disallow (Inherited)</>';
} }
//@phpstan-ignore-next-line This line is never reached, but PHPstorm complains otherwise
return '???'; return '???';
} }
} }

View file

@ -35,6 +35,7 @@ use App\Entity\LabelSystem\LabelProcessMode;
use App\Entity\LabelSystem\LabelProfile; use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parameters\AbstractParameter; use App\Entity\Parameters\AbstractParameter;
use App\Exceptions\AttachmentDownloadException; use App\Exceptions\AttachmentDownloadException;
use App\Exceptions\TwigModeException;
use App\Form\AdminPages\ImportType; use App\Form\AdminPages\ImportType;
use App\Form\AdminPages\MassCreationForm; use App\Form\AdminPages\MassCreationForm;
use App\Repository\AbstractPartsContainingRepository; use App\Repository\AbstractPartsContainingRepository;
@ -53,6 +54,7 @@ use InvalidArgumentException;
use Omines\DataTablesBundle\DataTableFactory; use Omines\DataTablesBundle\DataTableFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
@ -211,10 +213,14 @@ abstract class BaseAdminController extends AbstractController
//Show preview for LabelProfile if needed. //Show preview for LabelProfile if needed.
if ($entity instanceof LabelProfile) { if ($entity instanceof LabelProfile) {
$example = $this->barcodeExampleGenerator->getElement($entity->getOptions()->getSupportedElement()); $example = $this->barcodeExampleGenerator->getElement($entity->getOptions()->getSupportedElement());
$pdf_data = $this->labelGenerator->generateLabel($entity->getOptions(), $example); $pdf_data = null;
try {
$pdf_data = $this->labelGenerator->generateLabel($entity->getOptions(), $example);
} catch (TwigModeException $exception) {
$form->get('options')->get('lines')->addError(new FormError($exception->getSafeMessage()));
}
} }
/** @var AbstractPartsContainingRepository $repo */
$repo = $this->entityManager->getRepository($this->entity_class); $repo = $this->entityManager->getRepository($this->entity_class);
return $this->render($this->twig_template, [ return $this->render($this->twig_template, [
@ -390,7 +396,7 @@ abstract class BaseAdminController extends AbstractController
{ {
if ($entity instanceof AbstractPartsContainingDBElement) { if ($entity instanceof AbstractPartsContainingDBElement) {
/** @var AbstractPartsContainingRepository $repo */ /** @var AbstractPartsContainingRepository $repo */
$repo = $this->entityManager->getRepository($this->entity_class); $repo = $this->entityManager->getRepository($this->entity_class); //@phpstan-ignore-line
if ($repo->getPartsCount($entity) > 0) { if ($repo->getPartsCount($entity) > 0) {
$this->addFlash('error', t('entity.delete.must_not_contain_parts', ['%PATH%' => $entity->getFullPath()])); $this->addFlash('error', t('entity.delete.must_not_contain_parts', ['%PATH%' => $entity->getFullPath()]));

View file

@ -53,11 +53,11 @@ class AttachmentFileController extends AbstractController
} }
if ($attachment->isExternal()) { if ($attachment->isExternal()) {
throw new RuntimeException('You can not download external attachments!'); throw $this->createNotFoundException('The file for this attachment is external and can not stored locally!');
} }
if (!$helper->isFileExisting($attachment)) { if (!$helper->isFileExisting($attachment)) {
throw new RuntimeException('The file associated with the attachment is not existing!'); throw $this->createNotFoundException('The file associated with the attachment is not existing!');
} }
$file_path = $helper->toAbsoluteFilePath($attachment); $file_path = $helper->toAbsoluteFilePath($attachment);
@ -82,11 +82,11 @@ class AttachmentFileController extends AbstractController
} }
if ($attachment->isExternal()) { if ($attachment->isExternal()) {
throw new RuntimeException('You can not download external attachments!'); throw $this->createNotFoundException('The file for this attachment is external and can not stored locally!');
} }
if (!$helper->isFileExisting($attachment)) { if (!$helper->isFileExisting($attachment)) {
throw new RuntimeException('The file associated with the attachment is not existing!'); throw $this->createNotFoundException('The file associated with the attachment is not existing!');
} }
$file_path = $helper->toAbsoluteFilePath($attachment); $file_path = $helper->toAbsoluteFilePath($attachment);

View file

@ -23,10 +23,13 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;
use App\Form\InfoProviderSystem\PartSearchType; use App\Form\InfoProviderSystem\PartSearchType;
use App\Services\InfoProviderSystem\ExistingPartFinder;
use App\Services\InfoProviderSystem\PartInfoRetriever; use App\Services\InfoProviderSystem\PartInfoRetriever;
use App\Services\InfoProviderSystem\ProviderRegistry; use App\Services\InfoProviderSystem\ProviderRegistry;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity; use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -42,7 +45,9 @@ class InfoProviderController extends AbstractController
{ {
public function __construct(private readonly ProviderRegistry $providerRegistry, public function __construct(private readonly ProviderRegistry $providerRegistry,
private readonly PartInfoRetriever $infoRetriever) private readonly PartInfoRetriever $infoRetriever,
private readonly ExistingPartFinder $existingPartFinder
)
{ {
} }
@ -72,21 +77,49 @@ class InfoProviderController extends AbstractController
//When we are updating a part, use its name as keyword, to make searching easier //When we are updating a part, use its name as keyword, to make searching easier
//However we can only do this, if the form was not submitted yet //However we can only do this, if the form was not submitted yet
if ($update_target !== null && !$form->isSubmitted()) { if ($update_target !== null && !$form->isSubmitted()) {
$form->get('keyword')->setData($update_target->getName()); //Use the provider reference if available, otherwise use the manufacturer product number
$keyword = $update_target->getProviderReference()->getProviderId() ?? $update_target->getManufacturerProductNumber();
//Or the name if both are not available
if ($keyword === "") {
$keyword = $update_target->getName();
}
$form->get('keyword')->setData($keyword);
//If we are updating a part, which already has a provider, preselect that provider in the form
if ($update_target->getProviderReference()->getProviderKey() !== null) {
try {
$form->get('providers')->setData([$this->providerRegistry->getProviderByKey($update_target->getProviderReference()->getProviderKey())]);
} catch (\InvalidArgumentException $e) {
//If the provider is not found, just ignore it
}
}
} }
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$keyword = $form->get('keyword')->getData(); $keyword = $form->get('keyword')->getData();
$providers = $form->get('providers')->getData(); $providers = $form->get('providers')->getData();
$dtos = [];
try { try {
$results = $this->infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers); $dtos = $this->infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers);
} catch (ClientException $e) { } catch (ClientException $e) {
$this->addFlash('error', t('info_providers.search.error.client_exception')); $this->addFlash('error', t('info_providers.search.error.client_exception'));
$this->addFlash('error',$e->getMessage()); $this->addFlash('error',$e->getMessage());
//Log the exception //Log the exception
$exceptionLogger->error('Error during info provider search: ' . $e->getMessage(), ['exception' => $e]); $exceptionLogger->error('Error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
} }
// modify the array to an array of arrays that has a field for a matching local Part
// the advantage to use that format even when we don't look for local parts is that we
// always work with the same interface
$results = array_map(function ($result) {return ['dto' => $result, 'localPart' => null];}, $dtos);
if(!$update_target) {
foreach ($results as $index => $result) {
$results[$index]['localPart'] = $this->existingPartFinder->findFirstExisting($result['dto']);
}
}
} }
return $this->render('info_providers/search/part_search.html.twig', [ return $this->render('info_providers/search/part_search.html.twig', [

View file

@ -108,8 +108,31 @@ class LabelController extends AbstractController
$pdf_data = null; $pdf_data = null;
$filename = 'invalid.pdf'; $filename = 'invalid.pdf';
//Generate PDF either when the form is submitted and valid, or the form was not submit yet, and generate is set
if (($form->isSubmitted() && $form->isValid()) || ($generate && !$form->isSubmitted() && $profile instanceof LabelProfile)) { if (($form->isSubmitted() && $form->isValid()) || ($generate && !$form->isSubmitted() && $profile instanceof LabelProfile)) {
//Check if the label should be saved as profile
if ($form->get('save_profile')->isClicked() && $this->isGranted('@labels.create_profiles')) { //@phpstan-ignore-line Phpstan does not recognize the isClicked method
//Retrieve the profile name from the form
$new_name = $form->get('save_profile_name')->getData();
//ensure that the name is not empty
if ($new_name === '' || $new_name === null) {
$form->get('save_profile_name')->addError(new FormError($this->translator->trans('label_generator.profile_name_empty')));
goto render;
}
$profile = new LabelProfile();
$profile->setName($form->get('save_profile_name')->getData());
$profile->setOptions($form_options);
$this->em->persist($profile);
$this->em->flush();
$this->addFlash('success', 'label_generator.profile_saved');
return $this->redirectToRoute('label_dialog_profile', [
'profile' => $profile->getID(),
'target_id' => (string) $form->get('target_id')->getData()
]);
}
$target_id = (string) $form->get('target_id')->getData(); $target_id = (string) $form->get('target_id')->getData();
$targets = $this->findObjects($form_options->getSupportedElement(), $target_id); $targets = $this->findObjects($form_options->getSupportedElement(), $target_id);
if ($targets !== []) { if ($targets !== []) {
@ -117,7 +140,7 @@ class LabelController extends AbstractController
$pdf_data = $this->labelGenerator->generateLabel($form_options, $targets); $pdf_data = $this->labelGenerator->generateLabel($form_options, $targets);
$filename = $this->getLabelName($targets[0], $profile); $filename = $this->getLabelName($targets[0], $profile);
} catch (TwigModeException $exception) { } catch (TwigModeException $exception) {
$form->get('options')->get('lines')->addError(new FormError($exception->getMessage())); $form->get('options')->get('lines')->addError(new FormError($exception->getSafeMessage()));
} }
} else { } else {
//$this->addFlash('warning', 'label_generator.no_entities_found'); //$this->addFlash('warning', 'label_generator.no_entities_found');
@ -132,6 +155,7 @@ class LabelController extends AbstractController
} }
} }
render:
return $this->render('label_system/dialog.html.twig', [ return $this->render('label_system/dialog.html.twig', [
'form' => $form, 'form' => $form,
'pdf_data' => $pdf_data, 'pdf_data' => $pdf_data,
@ -152,7 +176,7 @@ class LabelController extends AbstractController
{ {
$id_array = $this->rangeParser->parse($ids); $id_array = $this->rangeParser->parse($ids);
/** @var DBElementRepository $repo */ /** @var DBElementRepository<AbstractDBElement> $repo */
$repo = $this->em->getRepository($type->getEntityClass()); $repo = $this->em->getRepository($type->getEntityClass());
return $repo->getElementsFromIDArray($id_array); return $repo->getElementsFromIDArray($id_array);

View file

@ -229,6 +229,10 @@ class PartController extends AbstractController
$dto = $infoRetriever->getDetails($providerKey, $providerId); $dto = $infoRetriever->getDetails($providerKey, $providerId);
$new_part = $infoRetriever->dtoToPart($dto); $new_part = $infoRetriever->dtoToPart($dto);
if ($new_part->getCategory() === null || $new_part->getCategory()->getID() === null) {
$this->addFlash('warning', t("part.create_from_info_provider.no_category_yet"));
}
return $this->renderPartForm('new', $request, $new_part, [ return $this->renderPartForm('new', $request, $new_part, [
'info_provider_dto' => $dto, 'info_provider_dto' => $dto,
]); ]);

View file

@ -66,6 +66,7 @@ class PartListsController extends AbstractController
$ids = $request->request->get('ids'); $ids = $request->request->get('ids');
$action = $request->request->get('action'); $action = $request->request->get('action');
$target = $request->request->get('target'); $target = $request->request->get('target');
$redirectResponse = null;
if (!$this->isCsrfTokenValid('table_action', $request->request->get('_token'))) { if (!$this->isCsrfTokenValid('table_action', $request->request->get('_token'))) {
$this->addFlash('error', 'csfr_invalid'); $this->addFlash('error', 'csfr_invalid');
@ -86,7 +87,7 @@ class PartListsController extends AbstractController
} }
//If the action handler returned a response, we use it, otherwise we redirect back to the previous page. //If the action handler returned a response, we use it, otherwise we redirect back to the previous page.
if (isset($redirectResponse) && $redirectResponse instanceof Response) { if ($redirectResponse !== null) {
return $redirectResponse; return $redirectResponse;
} }

View file

@ -42,10 +42,10 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Form\LabelSystem\ScanDialogType; use App\Form\LabelSystem\ScanDialogType;
use App\Services\LabelSystem\Barcodes\BarcodeScanHelper; use App\Services\LabelSystem\BarcodeScanner\BarcodeRedirector;
use App\Services\LabelSystem\Barcodes\BarcodeRedirector; use App\Services\LabelSystem\BarcodeScanner\BarcodeScanHelper;
use App\Services\LabelSystem\Barcodes\BarcodeScanResult; use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
use App\Services\LabelSystem\Barcodes\BarcodeSourceType; use App\Services\LabelSystem\BarcodeScanner\LocalBarcodeScanResult;
use Doctrine\ORM\EntityNotFoundException; use Doctrine\ORM\EntityNotFoundException;
use InvalidArgumentException; use InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -77,13 +77,21 @@ class ScanController extends AbstractController
$mode = $form['mode']->getData(); $mode = $form['mode']->getData();
} }
$infoModeData = null;
if ($input !== null) { if ($input !== null) {
try { try {
$scan_result = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null); $scan_result = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null);
try { //Perform a redirect if the info mode is not enabled
return $this->redirect($this->barcodeParser->getRedirectURL($scan_result)); if (!$form['info_mode']->getData()) {
} catch (EntityNotFoundException) { try {
$this->addFlash('success', 'scan.qr_not_found'); return $this->redirect($this->barcodeParser->getRedirectURL($scan_result));
} catch (EntityNotFoundException) {
$this->addFlash('success', 'scan.qr_not_found');
}
} else { //Otherwise retrieve infoModeData
$infoModeData = $scan_result->getDecodedForInfoMode();
} }
} catch (InvalidArgumentException) { } catch (InvalidArgumentException) {
$this->addFlash('error', 'scan.format_unknown'); $this->addFlash('error', 'scan.format_unknown');
@ -92,6 +100,7 @@ class ScanController extends AbstractController
return $this->render('label_system/scanner/scanner.html.twig', [ return $this->render('label_system/scanner/scanner.html.twig', [
'form' => $form, 'form' => $form,
'infoModeData' => $infoModeData,
]); ]);
} }
@ -109,7 +118,7 @@ class ScanController extends AbstractController
throw new InvalidArgumentException('Unknown type: '.$type); throw new InvalidArgumentException('Unknown type: '.$type);
} }
//Construct the scan result manually, as we don't have a barcode here //Construct the scan result manually, as we don't have a barcode here
$scan_result = new BarcodeScanResult( $scan_result = new LocalBarcodeScanResult(
target_type: BarcodeScanHelper::QR_TYPE_MAP[$type], target_type: BarcodeScanHelper::QR_TYPE_MAP[$type],
target_id: $id, target_id: $id,
//The routes are only used on the internal generated QR codes //The routes are only used on the internal generated QR codes

View file

@ -63,10 +63,10 @@ class ToolsController extends AbstractController
'default_theme' => $settings->system->customization->theme, 'default_theme' => $settings->system->customization->theme,
'enabled_locales' => $this->getParameter('partdb.locale_menu'), 'enabled_locales' => $this->getParameter('partdb.locale_menu'),
'demo_mode' => $this->getParameter('partdb.demo_mode'), 'demo_mode' => $this->getParameter('partdb.demo_mode'),
'gpdr_compliance' => $this->getParameter('partdb.gdpr_compliance'),
'use_gravatar' => $settings->system->privacy->useGravatar, 'use_gravatar' => $settings->system->privacy->useGravatar,
'gdpr_compliance' => $this->getParameter('partdb.gdpr_compliance'),
'email_password_reset' => $this->getParameter('partdb.users.email_pw_reset'), 'email_password_reset' => $this->getParameter('partdb.users.email_pw_reset'),
'enviroment' => $this->getParameter('kernel.environment'), 'environment' => $this->getParameter('kernel.environment'),
'is_debug' => $this->getParameter('kernel.debug'), 'is_debug' => $this->getParameter('kernel.debug'),
'email_sender' => $this->getParameter('partdb.mail.sender_email'), 'email_sender' => $this->getParameter('partdb.mail.sender_email'),
'email_sender_name' => $this->getParameter('partdb.mail.sender_name'), 'email_sender_name' => $this->getParameter('partdb.mail.sender_name'),

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\Parameters\AbstractParameter;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use App\Entity\Attachments\Attachment; use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Category; use App\Entity\Parts\Category;
@ -92,7 +93,7 @@ class TypeaheadController extends AbstractController
/** /**
* This function map the parameter type to the class, so we can access its repository * This function map the parameter type to the class, so we can access its repository
* @return class-string * @return class-string<AbstractParameter>
*/ */
private function typeToParameterClass(string $type): string private function typeToParameterClass(string $type): string
{ {
@ -155,7 +156,7 @@ class TypeaheadController extends AbstractController
//Ensure user has the correct permissions //Ensure user has the correct permissions
$this->denyAccessUnlessGranted('read', $test_obj); $this->denyAccessUnlessGranted('read', $test_obj);
/** @var ParameterRepository $repository */ /** @var ParameterRepository<AbstractParameter> $repository */
$repository = $entityManager->getRepository($class); $repository = $entityManager->getRepository($class);
$data = $repository->autocompleteParamName($query); $data = $repository->autocompleteParamName($query);

View file

@ -240,7 +240,10 @@ class UserSettingsController extends AbstractController
$page_need_reload = true; $page_need_reload = true;
} }
/** @var Form $form We need a form implementation for the next calls */ if (!$form instanceof Form) {
throw new RuntimeException('Form is not an instance of Form, so we cannot retrieve the clicked button!');
}
//Remove the avatar attachment from the user if requested //Remove the avatar attachment from the user if requested
if ($form->getClickedButton() && 'remove_avatar' === $form->getClickedButton()->getName() && $user->getMasterPictureAttachment() instanceof Attachment) { if ($form->getClickedButton() && 'remove_avatar' === $form->getClickedButton()->getName() && $user->getMasterPictureAttachment() instanceof Attachment) {
$em->remove($user->getMasterPictureAttachment()); $em->remove($user->getMasterPictureAttachment());

View file

@ -41,7 +41,7 @@ class APITokenFixtures extends Fixture implements DependentFixtureInterface
public function load(ObjectManager $manager): void public function load(ObjectManager $manager): void
{ {
/** @var User $admin_user */ /** @var User $admin_user */
$admin_user = $this->getReference(UserFixtures::ADMIN); $admin_user = $this->getReference(UserFixtures::ADMIN, User::class);
$read_only_token = new ApiToken(); $read_only_token = new ApiToken();
$read_only_token->setUser($admin_user); $read_only_token->setUser($admin_user);

View file

@ -35,7 +35,7 @@ use Doctrine\Persistence\ObjectManager;
class LogEntryFixtures extends Fixture implements DependentFixtureInterface class LogEntryFixtures extends Fixture implements DependentFixtureInterface
{ {
public function load(ObjectManager $manager) public function load(ObjectManager $manager): void
{ {
$this->createCategoryEntries($manager); $this->createCategoryEntries($manager);
$this->createDeletedCategory($manager); $this->createDeletedCategory($manager);

View file

@ -106,7 +106,7 @@ class PartFixtures extends Fixture implements DependentFixtureInterface
$partLot2->setComment('Test'); $partLot2->setComment('Test');
$partLot2->setNeedsRefill(true); $partLot2->setNeedsRefill(true);
$partLot2->setStorageLocation($manager->find(StorageLocation::class, 3)); $partLot2->setStorageLocation($manager->find(StorageLocation::class, 3));
$partLot2->setVendorBarcode('lot2_vendor_barcode'); $partLot2->setUserBarcode('lot2_vendor_barcode');
$part->addPartLot($partLot2); $part->addPartLot($partLot2);
$orderdetail = new Orderdetail(); $orderdetail = new Orderdetail();

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\DataFixtures; namespace App\DataFixtures;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User; use App\Entity\UserSystem\User;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Common\DataFixtures\DependentFixtureInterface;
@ -41,7 +42,7 @@ class UserFixtures extends Fixture implements DependentFixtureInterface
{ {
$anonymous = new User(); $anonymous = new User();
$anonymous->setName('anonymous'); $anonymous->setName('anonymous');
$anonymous->setGroup($this->getReference(GroupFixtures::READONLY)); $anonymous->setGroup($this->getReference(GroupFixtures::READONLY, Group::class));
$anonymous->setNeedPwChange(false); $anonymous->setNeedPwChange(false);
$anonymous->setPassword($this->encoder->hashPassword($anonymous, 'test')); $anonymous->setPassword($this->encoder->hashPassword($anonymous, 'test'));
$manager->persist($anonymous); $manager->persist($anonymous);
@ -50,7 +51,7 @@ class UserFixtures extends Fixture implements DependentFixtureInterface
$admin->setName('admin'); $admin->setName('admin');
$admin->setPassword($this->encoder->hashPassword($admin, 'test')); $admin->setPassword($this->encoder->hashPassword($admin, 'test'));
$admin->setNeedPwChange(false); $admin->setNeedPwChange(false);
$admin->setGroup($this->getReference(GroupFixtures::ADMINS)); $admin->setGroup($this->getReference(GroupFixtures::ADMINS, Group::class));
$manager->persist($admin); $manager->persist($admin);
$this->addReference(self::ADMIN, $admin); $this->addReference(self::ADMIN, $admin);
@ -60,7 +61,7 @@ class UserFixtures extends Fixture implements DependentFixtureInterface
$user->setEmail('user@invalid.invalid'); $user->setEmail('user@invalid.invalid');
$user->setFirstName('Test')->setLastName('User'); $user->setFirstName('Test')->setLastName('User');
$user->setPassword($this->encoder->hashPassword($user, 'test')); $user->setPassword($this->encoder->hashPassword($user, 'test'));
$user->setGroup($this->getReference(GroupFixtures::USERS)); $user->setGroup($this->getReference(GroupFixtures::USERS, Group::class));
$manager->persist($user); $manager->persist($user);
$noread = new User(); $noread = new User();

View file

@ -34,6 +34,7 @@ use App\Services\EntityURLGenerator;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider; use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter; use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter;
use Omines\DataTablesBundle\Column\NumberColumn;
use Omines\DataTablesBundle\Column\TextColumn; use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable; use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface; use Omines\DataTablesBundle\DataTableTypeInterface;
@ -84,6 +85,11 @@ final class AttachmentDataTable implements DataTableTypeInterface
}, },
]); ]);
$dataTable->add('id', NumberColumn::class, [
'label' => $this->translator->trans('part.table.id'),
'visible' => false,
]);
$dataTable->add('name', TextColumn::class, [ $dataTable->add('name', TextColumn::class, [
'label' => 'attachment.edit.name', 'label' => 'attachment.edit.name',
'orderField' => 'NATSORT(attachment.name)', 'orderField' => 'NATSORT(attachment.name)',

View file

@ -137,7 +137,7 @@ class EntityConstraint extends AbstractConstraint
} }
//We need to handle null values differently, as they can not be compared with == or != //We need to handle null values differently, as they can not be compared with == or !=
if (!$this->value instanceof AbstractDBElement) { if ($this->value === null) {
if($this->operator === '=' || $this->operator === 'INCLUDING_CHILDREN') { if($this->operator === '=' || $this->operator === 'INCLUDING_CHILDREN') {
$queryBuilder->andWhere(sprintf("%s IS NULL", $this->property)); $queryBuilder->andWhere(sprintf("%s IS NULL", $this->property));
return; return;
@ -152,8 +152,9 @@ class EntityConstraint extends AbstractConstraint
} }
if($this->operator === '=' || $this->operator === '!=') { if($this->operator === '=' || $this->operator === '!=') {
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, $this->operator, $this->value); //Include null values on != operator, so that really all values are returned that are not equal to the given value
return; $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 //Otherwise retrieve the children list and apply the operator to it
@ -168,7 +169,8 @@ class EntityConstraint extends AbstractConstraint
} }
if ($this->operator === 'EXCLUDING_CHILDREN') { 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; return;
} }
} else { } else {

View file

@ -56,8 +56,14 @@ trait FilterTrait
/** /**
* Adds a simple constraint in the form of (property OPERATOR value) (e.g. "part.name = :name") to the given query builder. * Adds a simple constraint in the form of (property OPERATOR value) (e.g. "part.name = :name") to the given query builder.
* @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, mixed $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') { if ($comparison_operator === 'IN' || $comparison_operator === 'NOT IN') {
$expression = sprintf("%s %s (:%s)", $property, $comparison_operator, $parameterIdentifier); $expression = sprintf("%s %s (:%s)", $property, $comparison_operator, $parameterIdentifier);
@ -65,6 +71,10 @@ trait FilterTrait
$expression = sprintf("%s %s :%s", $property, $comparison_operator, $parameterIdentifier); $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" 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); $queryBuilder->andHaving($expression);
} else { } else {

View file

@ -85,15 +85,18 @@ class TagsConstraint extends AbstractConstraint
*/ */
protected function getExpressionForTag(QueryBuilder $queryBuilder, string $tag): 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); $tag_identifier_prefix = uniqid($this->identifier . '_', false);
$expr = $queryBuilder->expr(); $expr = $queryBuilder->expr();
$tmp = $expr->orX( $tmp = $expr->orX(
$expr->like($this->property, ':' . $tag_identifier_prefix . '_1'), 'ILIKE(' . $this->property . ', :' . $tag_identifier_prefix . '_1) = TRUE',
$expr->like($this->property, ':' . $tag_identifier_prefix . '_2'), 'ILIKE(' . $this->property . ', :' . $tag_identifier_prefix . '_2) = TRUE',
$expr->like($this->property, ':' . $tag_identifier_prefix . '_3'), 'ILIKE(' . $this->property . ', :' . $tag_identifier_prefix . '_3) = TRUE',
$expr->eq($this->property, ':' . $tag_identifier_prefix . '_4'), '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) //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)
@ -130,6 +133,7 @@ class TagsConstraint extends AbstractConstraint
return; 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') { if ($this->operator === 'NONE') {
$queryBuilder->andWhere($queryBuilder->expr()->not($queryBuilder->expr()->orX(...$tagsExpressions))); $queryBuilder->andWhere($queryBuilder->expr()->not($queryBuilder->expr()->orX(...$tagsExpressions)));
return; return;

View file

@ -107,7 +107,8 @@ class TextConstraint extends AbstractConstraint
} }
if ($like_value !== null) { 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; return;
} }

View file

@ -21,7 +21,6 @@ declare(strict_types=1);
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
namespace App\DataTables\Filters; namespace App\DataTables\Filters;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
class PartSearchFilter implements FilterInterface class PartSearchFilter implements FilterInterface
@ -132,15 +131,15 @@ class PartSearchFilter implements FilterInterface
return sprintf("REGEXP(%s, :search_query) = TRUE", $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); }, $fields_to_search);
//Add Or concatation of the expressions to our query //Add Or concatenation of the expressions to our query
$queryBuilder->andWhere( $queryBuilder->andWhere(
$queryBuilder->expr()->orX(...$expressions) $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) { if ($this->regex) {
$queryBuilder->setParameter('search_query', $this->keyword); $queryBuilder->setParameter('search_query', $this->keyword);
} else { } else {

View file

@ -138,7 +138,8 @@ final class PartsDataTable implements DataTableTypeInterface
]) ])
->add('storelocation', TextColumn::class, [ ->add('storelocation', TextColumn::class, [
'label' => $this->translator->trans('part.table.storeLocations'), 'label' => $this->translator->trans('part.table.storeLocations'),
'orderField' => 'NATSORT(_storelocations.name)', //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), 'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
], alias: 'storage_location') ], alias: 'storage_location')

View file

@ -87,16 +87,14 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
if(!$context->getPart() instanceof Part) { if(!$context->getPart() instanceof Part) {
return htmlspecialchars((string) $context->getName()); return htmlspecialchars((string) $context->getName());
} }
if($context->getPart() instanceof Part) {
$tmp = $this->partDataTableHelper->renderName($context->getPart());
if($context->getName() !== null && $context->getName() !== '') {
$tmp .= '<br><b>'.htmlspecialchars($context->getName()).'</b>';
}
return $tmp;
}
//@phpstan-ignore-next-line //Part exists if we reach this point
throw new \RuntimeException('This should never happen!');
$tmp = $this->partDataTableHelper->renderName($context->getPart());
if($context->getName() !== null && $context->getName() !== '') {
$tmp .= '<br><b>'.htmlspecialchars($context->getName()).'</b>';
}
return $tmp;
}, },
]) ])
->add('ipn', TextColumn::class, [ ->add('ipn', TextColumn::class, [

View file

@ -0,0 +1,71 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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) . ')';
}
}

View file

@ -44,15 +44,13 @@ class SQLiteRegexExtensionMiddlewareDriver extends AbstractDriverMiddleware
$native_connection = $connection->getNativeConnection(); $native_connection = $connection->getNativeConnection();
//Ensure that the function really exists on the connection, as it is marked as experimental according to PHP documentation //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' )) { if($native_connection instanceof \PDO) {
$native_connection->sqliteCreateFunction('REGEXP', self::regexp(...), 2, \PDO::SQLITE_DETERMINISTIC); $native_connection->sqliteCreateFunction('REGEXP', self::regexp(...), 2, \PDO::SQLITE_DETERMINISTIC);
$native_connection->sqliteCreateFunction('FIELD', self::field(...), -1, \PDO::SQLITE_DETERMINISTIC); $native_connection->sqliteCreateFunction('FIELD', self::field(...), -1, \PDO::SQLITE_DETERMINISTIC);
$native_connection->sqliteCreateFunction('FIELD2', self::field2(...), 2, \PDO::SQLITE_DETERMINISTIC); $native_connection->sqliteCreateFunction('FIELD2', self::field2(...), 2, \PDO::SQLITE_DETERMINISTIC);
//Create a new collation for natural sorting //Create a new collation for natural sorting
if (method_exists($native_connection, 'sqliteCreateCollation')) { $native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...));
$native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...));
}
} }
} }

View file

@ -44,7 +44,7 @@ class DoNotUsePurgerFactory implements PurgerFactory
throw new \LogicException('Do not use doctrine:fixtures:load directly. Use partdb:fixtures:load instead!'); throw new \LogicException('Do not use doctrine:fixtures:load directly. Use partdb:fixtures:load instead!');
} }
public function setEntityManager(EntityManagerInterface $em) public function setEntityManager(EntityManagerInterface $em): void
{ {
// TODO: Implement setEntityManager() method. // TODO: Implement setEntityManager() method.
} }

View file

@ -531,7 +531,7 @@ abstract class Attachment extends AbstractNamedDBElement
$url = str_replace(' ', '%20', $url); $url = str_replace(' ', '%20', $url);
//Only set if the URL is not empty //Only set if the URL is not empty
if ($url !== null && $url !== '') { if ($url !== '') {
if (str_contains($url, '%BASE%') || str_contains($url, '%MEDIA%')) { if (str_contains($url, '%BASE%') || str_contains($url, '%MEDIA%')) {
throw new InvalidArgumentException('You can not reference internal files via the url field! But nice try!'); throw new InvalidArgumentException('You can not reference internal files via the url field! But nice try!');
} }

View file

@ -33,7 +33,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Serializer\Annotation\Groups;
/** /**
* @template-covariant AT of Attachment * @template AT of Attachment
*/ */
#[ORM\MappedSuperclass(repositoryClass: AttachmentContainingDBElementRepository::class)] #[ORM\MappedSuperclass(repositoryClass: AttachmentContainingDBElementRepository::class)]
abstract class AttachmentContainingDBElement extends AbstractNamedDBElement implements HasMasterAttachmentInterface, HasAttachmentsInterface abstract class AttachmentContainingDBElement extends AbstractNamedDBElement implements HasMasterAttachmentInterface, HasAttachmentsInterface

View file

@ -33,8 +33,8 @@ use Symfony\Component\Validator\Constraints as Assert;
/** /**
* This abstract class is used for companies like suppliers or manufacturers. * This abstract class is used for companies like suppliers or manufacturers.
* *
* @template-covariant AT of Attachment * @template AT of Attachment
* @template-covariant PT of AbstractParameter * @template PT of AbstractParameter
* @extends AbstractPartsContainingDBElement<AT, PT> * @extends AbstractPartsContainingDBElement<AT, PT>
*/ */
#[ORM\MappedSuperclass] #[ORM\MappedSuperclass]

View file

@ -31,8 +31,8 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Serializer\Annotation\Groups;
/** /**
* @template-covariant AT of Attachment * @template AT of Attachment
* @template-covariant PT of AbstractParameter * @template PT of AbstractParameter
* @extends AbstractStructuralDBElement<AT, PT> * @extends AbstractStructuralDBElement<AT, PT>
*/ */
#[ORM\MappedSuperclass(repositoryClass: AbstractPartsContainingRepository::class)] #[ORM\MappedSuperclass(repositoryClass: AbstractPartsContainingRepository::class)]

View file

@ -53,8 +53,8 @@ use Symfony\Component\Serializer\Annotation\Groups;
* *
* @see \App\Tests\Entity\Base\AbstractStructuralDBElementTest * @see \App\Tests\Entity\Base\AbstractStructuralDBElementTest
* *
* @template-covariant AT of Attachment * @template AT of Attachment
* @template-covariant PT of AbstractParameter * @template PT of AbstractParameter
* @template-use ParametersTrait<PT> * @template-use ParametersTrait<PT>
* @extends AttachmentContainingDBElement<AT> * @extends AttachmentContainingDBElement<AT>
* @uses ParametersTrait<PT> * @uses ParametersTrait<PT>

View file

@ -22,6 +22,8 @@ declare(strict_types=1);
*/ */
namespace App\Entity\LabelSystem; namespace App\Entity\LabelSystem;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot; use App\Entity\Parts\PartLot;
use App\Entity\Parts\StorageLocation; use App\Entity\Parts\StorageLocation;
@ -34,7 +36,7 @@ enum LabelSupportedElement: string
/** /**
* Returns the entity class for the given element type * Returns the entity class for the given element type
* @return string * @return class-string<AbstractDBElement>
*/ */
public function getEntityClass(): string public function getEntityClass(): string
{ {

View file

@ -44,9 +44,9 @@ namespace App\Entity\LogSystem;
use App\Entity\Base\AbstractDBElement; use App\Entity\Base\AbstractDBElement;
use App\Entity\UserSystem\User; use App\Entity\UserSystem\User;
use App\Events\SecurityEvents; use App\Events\SecurityEvents;
use App\Helpers\IPAnonymizer;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException; use InvalidArgumentException;
use Symfony\Component\HttpFoundation\IpUtils;
/** /**
* This log entry is created when something security related to a user happens. * This log entry is created when something security related to a user happens.
@ -127,14 +127,14 @@ class SecurityEventLogEntry extends AbstractLogEntry
* Sets the IP address used to log in the user. * Sets the IP address used to log in the user.
* *
* @param string $ip the IP address used to log in the user * @param string $ip the IP address used to log in the user
* @param bool $anonymize Anonymize the IP address (remove last block) to be GPDR compliant * @param bool $anonymize Anonymize the IP address (remove last block) to be GDPR compliant
* *
* @return $this * @return $this
*/ */
public function setIPAddress(string $ip, bool $anonymize = true): self public function setIPAddress(string $ip, bool $anonymize = true): self
{ {
if ($anonymize) { if ($anonymize) {
$ip = IpUtils::anonymize($ip); $ip = IPAnonymizer::anonymize($ip);
} }
$this->extra['i'] = $ip; $this->extra['i'] = $ip;

View file

@ -22,8 +22,9 @@ declare(strict_types=1);
namespace App\Entity\LogSystem; namespace App\Entity\LogSystem;
use App\Helpers\IPAnonymizer;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\IpUtils;
/** /**
* This log entry is created when a user logs in. * This log entry is created when a user logs in.
@ -52,14 +53,14 @@ class UserLoginLogEntry extends AbstractLogEntry
* Sets the IP address used to log in the user. * Sets the IP address used to log in the user.
* *
* @param string $ip the IP address used to log in the user * @param string $ip the IP address used to log in the user
* @param bool $anonymize Anonymize the IP address (remove last block) to be GPDR compliant * @param bool $anonymize Anonymize the IP address (remove last block) to be GDPR compliant
* *
* @return $this * @return $this
*/ */
public function setIPAddress(string $ip, bool $anonymize = true): self public function setIPAddress(string $ip, bool $anonymize = true): self
{ {
if ($anonymize) { if ($anonymize) {
$ip = IpUtils::anonymize($ip); $ip = IPAnonymizer::anonymize($ip);
} }
$this->extra['i'] = $ip; $this->extra['i'] = $ip;

View file

@ -22,8 +22,8 @@ declare(strict_types=1);
namespace App\Entity\LogSystem; namespace App\Entity\LogSystem;
use App\Helpers\IPAnonymizer;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\IpUtils;
#[ORM\Entity] #[ORM\Entity]
class UserLogoutLogEntry extends AbstractLogEntry class UserLogoutLogEntry extends AbstractLogEntry
@ -49,14 +49,14 @@ class UserLogoutLogEntry extends AbstractLogEntry
* Sets the IP address used to log in the user. * Sets the IP address used to log in the user.
* *
* @param string $ip the IP address used to log in the user * @param string $ip the IP address used to log in the user
* @param bool $anonymize Anonymize the IP address (remove last block) to be GPDR compliant * @param bool $anonymize Anonymize the IP address (remove last block) to be GDPR compliant
* *
* @return $this * @return $this
*/ */
public function setIPAddress(string $ip, bool $anonymize = true): self public function setIPAddress(string $ip, bool $anonymize = true): self
{ {
if ($anonymize) { if ($anonymize) {
$ip = IpUtils::anonymize($ip); $ip = IPAnonymizer::anonymize($ip);
} }
$this->extra['i'] = $ip; $this->extra['i'] = $ip;

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Entity\Parts; namespace App\Entity\Parts;
use App\ApiPlatform\Filter\TagFilter;
use Doctrine\Common\Collections\Criteria; use Doctrine\Common\Collections\Criteria;
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
@ -97,7 +98,8 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ApiFilter(PropertyFilter::class)] #[ApiFilter(PropertyFilter::class)]
#[ApiFilter(EntityFilter::class, properties: ["category", "footprint", "manufacturer", "partUnit"])] #[ApiFilter(EntityFilter::class, properties: ["category", "footprint", "manufacturer", "partUnit"])]
#[ApiFilter(PartStoragelocationFilter::class, properties: ["storage_location"])] #[ApiFilter(PartStoragelocationFilter::class, properties: ["storage_location"])]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "description", "ipn", "tags", "manufacturer_product_number"])] #[ApiFilter(LikeFilter::class, properties: ["name", "comment", "description", "ipn", "manufacturer_product_number"])]
#[ApiFilter(TagFilter::class, properties: ["tags"])]
#[ApiFilter(BooleanFilter::class, properties: ["favorite" , "needs_review"])] #[ApiFilter(BooleanFilter::class, properties: ["favorite" , "needs_review"])]
#[ApiFilter(RangeFilter::class, properties: ["mass", "minamount"])] #[ApiFilter(RangeFilter::class, properties: ["mass", "minamount"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)] #[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
@ -118,7 +120,7 @@ class Part extends AttachmentContainingDBElement
/** @var Collection<int, PartParameter> /** @var Collection<int, PartParameter>
*/ */
#[Assert\Valid] #[Assert\Valid]
#[Groups(['full', 'part:read', 'part:write'])] #[Groups(['full', 'part:read', 'part:write', 'import'])]
#[ORM\OneToMany(mappedBy: 'element', targetEntity: PartParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'element', targetEntity: PartParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])] #[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
#[UniqueObjectCollection(fields: ['name', 'group', 'element'])] #[UniqueObjectCollection(fields: ['name', 'group', 'element'])]

View file

@ -68,7 +68,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Index(columns: ['needs_refill'], name: 'part_lots_idx_needs_refill')] #[ORM\Index(columns: ['needs_refill'], name: 'part_lots_idx_needs_refill')]
#[ORM\Index(columns: ['vendor_barcode'], name: 'part_lots_idx_barcode')] #[ORM\Index(columns: ['vendor_barcode'], name: 'part_lots_idx_barcode')]
#[ValidPartLot] #[ValidPartLot]
#[UniqueEntity(['vendor_barcode'], message: 'validator.part_lot.vendor_barcode_must_be_unique')] #[UniqueEntity(['user_barcode'], message: 'validator.part_lot.vendor_barcode_must_be_unique')]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new Get(security: 'is_granted("read", object)'), new Get(security: 'is_granted("read", object)'),
@ -166,10 +166,10 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
/** /**
* @var string|null The content of the barcode of this part lot (e.g. a barcode on the package put by the vendor) * @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(type: Types::STRING, nullable: true)] #[ORM\Column(name: "vendor_barcode", type: Types::STRING, nullable: true)]
#[Groups(['part_lot:read', 'part_lot:write'])] #[Groups(['part_lot:read', 'part_lot:write'])]
#[Length(max: 255)] #[Length(max: 255)]
protected ?string $vendor_barcode = null; protected ?string $user_barcode = null;
public function __clone() public function __clone()
{ {
@ -185,7 +185,6 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
* *
* @return bool|null True, if the part lot is expired. Returns null, if no expiration date was set. * @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 public function isExpired(): ?bool
{ {
@ -376,19 +375,19 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
* null if no barcode is set. * null if no barcode is set.
* @return string|null * @return string|null
*/ */
public function getVendorBarcode(): ?string public function getUserBarcode(): ?string
{ {
return $this->vendor_barcode; 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). * Set the content of the barcode of this part lot (e.g. a barcode on the package put by the vendor).
* @param string|null $vendor_barcode * @param string|null $user_barcode
* @return $this * @return $this
*/ */
public function setVendorBarcode(?string $vendor_barcode): PartLot public function setUserBarcode(?string $user_barcode): PartLot
{ {
$this->vendor_barcode = $vendor_barcode; $this->user_barcode = $user_barcode;
return $this; return $this;
} }

View file

@ -103,7 +103,7 @@ class Supplier extends AbstractCompany
protected ?AbstractStructuralDBElement $parent = null; protected ?AbstractStructuralDBElement $parent = null;
/** /**
* @var Collection<int, Orderdetail>|Orderdetail[] * @var Collection<int, Orderdetail>
*/ */
#[ORM\OneToMany(mappedBy: 'supplier', targetEntity: Orderdetail::class)] #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: Orderdetail::class)]
protected Collection $orderdetails; protected Collection $orderdetails;

View file

@ -333,7 +333,6 @@ class Project extends AbstractStructuralDBElement
{ {
//If this project has subprojects, and these have builds part, they must be included in the BOM //If this project has subprojects, and these have builds part, they must be included in the BOM
foreach ($this->getChildren() as $child) { foreach ($this->getChildren() as $child) {
/** @var $child Project */
if (!$child->getBuildPart() instanceof Part) { if (!$child->getBuildPart() instanceof Part) {
continue; continue;
} }

View file

@ -102,7 +102,7 @@ use Jbtronics\TFAWebauthn\Model\TwoFactorInterface as WebauthnTwoFactorInterface
#[ApiFilter(LikeFilter::class, properties: ["name", "aboutMe"])] #[ApiFilter(LikeFilter::class, properties: ["name", "aboutMe"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)] #[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])] #[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
#[NoLockout] #[NoLockout(groups: ['permissions:edit'])]
class User extends AttachmentContainingDBElement implements UserInterface, HasPermissionsInterface, TwoFactorInterface, class User extends AttachmentContainingDBElement implements UserInterface, HasPermissionsInterface, TwoFactorInterface,
BackupCodeInterface, TrustedDeviceInterface, WebauthnTwoFactorInterface, PreferredProviderInterface, PasswordAuthenticatedUserInterface, SamlUserInterface BackupCodeInterface, TrustedDeviceInterface, WebauthnTwoFactorInterface, PreferredProviderInterface, PasswordAuthenticatedUserInterface, SamlUserInterface
{ {
@ -256,7 +256,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
protected ?string $password = null; protected ?string $password = null;
#[Assert\NotBlank] #[Assert\NotBlank]
#[Assert\Regex('/^[\w\.\+\-\$]+$/', message: 'user.invalid_username')] #[Assert\Regex('/^[\w\.\+\-\$]+[\w\.\+\-\$\@]*$/', message: 'user.invalid_username')]
#[Groups(['user:read'])] #[Groups(['user:read'])]
protected string $name = ''; protected string $name = '';
@ -893,8 +893,6 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
* @param string[] $codes An array containing the backup codes * @param string[] $codes An array containing the backup codes
* *
* @return $this * @return $this
*
* @throws Exception If an error with the datetime occurs
*/ */
public function setBackupCodes(array $codes): self public function setBackupCodes(array $codes): self
{ {

View file

@ -1,57 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\WebpackEncoreBundle\Event\RenderAssetTagEvent;
/**
* This class fixes the wrong pathes generated by webpack using the auto publicPath mode.
* Basically it replaces the wrong /auto/ part of the path with the correct /build/ in all encore entrypoints.
*/
class WebpackAutoPathSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
RenderAssetTagEvent::class => 'onRenderAssetTag'
];
}
public function onRenderAssetTag(RenderAssetTagEvent $event): void
{
if ($event->isScriptTag()) {
$event->setAttribute('src', $this->resolveAuto($event->getUrl()));
}
if ($event->isLinkTag()) {
$event->setAttribute('href', $this->resolveAuto($event->getUrl()));
}
}
private function resolveAuto(string $path): string
{
//Replace the first occurence of /auto/ with /build/ to get the correct path
return preg_replace('/\/auto\//', '/build/', $path, 1);
}
}

View file

@ -46,8 +46,23 @@ use Twig\Error\Error;
class TwigModeException extends RuntimeException class TwigModeException extends RuntimeException
{ {
private const PROJECT_PATH = __DIR__ . '/../../';
public function __construct(?Error $previous = null) public function __construct(?Error $previous = null)
{ {
parent::__construct($previous->getMessage(), 0, $previous); 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());
}
} }

View file

@ -1,228 +0,0 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Form\Fixes;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
/**
* Same as the default NumberToLocalizedStringTransformer, but with a fix for the decimal separator.
* See https://github.com/symfony/symfony/pull/57861
*/
class FixedNumberToLocalizedStringTransformer implements DataTransformerInterface
{
protected $grouping;
protected $roundingMode;
private ?int $scale;
private ?string $locale;
public function __construct(?int $scale = null, ?bool $grouping = false, ?int $roundingMode = \NumberFormatter::ROUND_HALFUP, ?string $locale = null)
{
$this->scale = $scale;
$this->grouping = $grouping ?? false;
$this->roundingMode = $roundingMode ?? \NumberFormatter::ROUND_HALFUP;
$this->locale = $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.');
}
$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;
}
/**
* Transforms a localized number into an integer or float.
*
* @param string $value The localized value
*
* @throws TransformationFailedException if the given value is not a string
* or if the value cannot be transformed
*/
public function reverseTransform(mixed $value): int|float|null
{
if (null !== $value && !\is_string($value)) {
throw new TransformationFailedException('Expected a string.');
}
if (null === $value || '' === $value) {
return null;
}
if (\in_array($value, ['NaN', 'NAN', 'nan'], true)) {
throw new TransformationFailedException('"NaN" is not a valid number.');
}
$position = 0;
$formatter = $this->getNumberFormatter();
$groupSep = $formatter->getSymbol(\NumberFormatter::GROUPING_SEPARATOR_SYMBOL);
$decSep = $formatter->getSymbol(\NumberFormatter::DECIMAL_SEPARATOR_SYMBOL);
if ('.' !== $decSep && (!$this->grouping || '.' !== $groupSep)) {
$value = str_replace('.', $decSep, $value);
}
if (',' !== $decSep && (!$this->grouping || ',' !== $groupSep)) {
$value = str_replace(',', $decSep, $value);
}
//If the value is in exponential notation with a negative exponent, we end up with a float value too
if (str_contains($value, $decSep) || stripos($value, 'e-') !== false) {
$type = \NumberFormatter::TYPE_DOUBLE;
} else {
$type = \PHP_INT_SIZE === 8
? \NumberFormatter::TYPE_INT64
: \NumberFormatter::TYPE_INT32;
}
$result = $formatter->parse($value, $type, $position);
if (intl_is_failure($formatter->getErrorCode())) {
throw new TransformationFailedException($formatter->getErrorMessage());
}
if ($result >= \PHP_INT_MAX || $result <= -\PHP_INT_MAX) {
throw new TransformationFailedException('I don\'t have a clear idea what infinity looks like.');
}
$result = $this->castParsedValue($result);
if (false !== $encoding = mb_detect_encoding($value, null, true)) {
$length = mb_strlen($value, $encoding);
$remainder = mb_substr($value, $position, $length, $encoding);
} else {
$length = \strlen($value);
$remainder = substr($value, $position, $length);
}
// After parsing, position holds the index of the character where the
// parsing stopped
if ($position < $length) {
// Check if there are unrecognized characters at the end of the
// number (excluding whitespace characters)
$remainder = trim($remainder, " \t\n\r\0\x0b\xc2\xa0");
if ('' !== $remainder) {
throw new TransformationFailedException(sprintf('The number contains unrecognized characters: "%s".', $remainder));
}
}
// NumberFormatter::parse() does not round
return $this->round($result);
}
/**
* Returns a preconfigured \NumberFormatter instance.
*/
protected function getNumberFormatter(): \NumberFormatter
{
$formatter = new \NumberFormatter($this->locale ?? \Locale::getDefault(), \NumberFormatter::DECIMAL);
if (null !== $this->scale) {
$formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $this->scale);
$formatter->setAttribute(\NumberFormatter::ROUNDING_MODE, $this->roundingMode);
}
$formatter->setAttribute(\NumberFormatter::GROUPING_USED, $this->grouping);
return $formatter;
}
/**
* @internal
*/
protected function castParsedValue(int|float $value): int|float
{
if (\is_int($value) && $value === (int) $float = (float) $value) {
return $float;
}
return $value;
}
/**
* Rounds a number according to the configured scale and rounding mode.
*/
private function round(int|float $number): int|float
{
if (null !== $this->scale && null !== $this->roundingMode) {
// shift number to maintain the correct scale during rounding
$roundingCoef = 10 ** $this->scale;
// string representation to avoid rounding errors, similar to bcmul()
$number = (string) ($number * $roundingCoef);
switch ($this->roundingMode) {
case \NumberFormatter::ROUND_CEILING:
$number = ceil($number);
break;
case \NumberFormatter::ROUND_FLOOR:
$number = floor($number);
break;
case \NumberFormatter::ROUND_UP:
$number = $number > 0 ? ceil($number) : floor($number);
break;
case \NumberFormatter::ROUND_DOWN:
$number = $number > 0 ? floor($number) : ceil($number);
break;
case \NumberFormatter::ROUND_HALFEVEN:
$number = round($number, 0, \PHP_ROUND_HALF_EVEN);
break;
case \NumberFormatter::ROUND_HALFUP:
$number = round($number, 0, \PHP_ROUND_HALF_UP);
break;
case \NumberFormatter::ROUND_HALFDOWN:
$number = round($number, 0, \PHP_ROUND_HALF_DOWN);
break;
}
$number = 1 === $roundingCoef ? (int) $number : $number / $roundingCoef;
}
return $number;
}
}

View file

@ -71,6 +71,22 @@ class LabelDialogType extends AbstractType
'label' => false, 'label' => false,
'disabled' => !$this->security->isGranted('@labels.edit_options') || $options['disable_options'], '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, [ $builder->add('update', SubmitType::class, [
'label' => 'label_generator.update', 'label' => 'label_generator.update',
]); ]);

View file

@ -41,8 +41,9 @@ declare(strict_types=1);
namespace App\Form\LabelSystem; namespace App\Form\LabelSystem;
use App\Services\LabelSystem\Barcodes\BarcodeSourceType; use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
use Symfony\Component\Form\AbstractType; 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\EnumType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
@ -55,6 +56,8 @@ class ScanDialogType extends AbstractType
{ {
$builder->add('input', TextType::class, [ $builder->add('input', TextType::class, [
'label' => 'scan_dialog.input', 'label' => 'scan_dialog.input',
//Do not trim the input, otherwise this damages Format06 barcodes which end with non-printable characters
'trim' => false,
'attr' => [ 'attr' => [
'autofocus' => true, 'autofocus' => true,
'id' => 'scan_dialog_input', 'id' => 'scan_dialog_input',
@ -71,9 +74,14 @@ class ScanDialogType extends AbstractType
null => 'scan_dialog.mode.auto', null => 'scan_dialog.mode.auto',
BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal', BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal',
BarcodeSourceType::IPN => 'scan_dialog.mode.ipn', BarcodeSourceType::IPN => 'scan_dialog.mode.ipn',
BarcodeSourceType::VENDOR => 'scan_dialog.mode.vendor', 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, [ $builder->add('submit', SubmitType::class, [

View file

@ -102,7 +102,7 @@ class ParameterType extends AbstractType
'step' => 'any', 'step' => 'any',
'placeholder' => 'parameters.max.placeholder', 'placeholder' => 'parameters.max.placeholder',
'class' => 'form-control-sm', 'class' => 'form-control-sm',
'style' => 'max-width: 12ch;', 'style' => 'max-width: 25ch;',
], ],
]); ]);
$builder->add('value_min', ExponentialNumberType::class, [ $builder->add('value_min', ExponentialNumberType::class, [
@ -113,7 +113,7 @@ class ParameterType extends AbstractType
'step' => 'any', 'step' => 'any',
'placeholder' => 'parameters.min.placeholder', 'placeholder' => 'parameters.min.placeholder',
'class' => 'form-control-sm', 'class' => 'form-control-sm',
'style' => 'max-width: 12ch;', 'style' => 'max-width: 25ch;',
], ],
]); ]);
$builder->add('value_typical', ExponentialNumberType::class, [ $builder->add('value_typical', ExponentialNumberType::class, [
@ -124,7 +124,7 @@ class ParameterType extends AbstractType
'step' => 'any', 'step' => 'any',
'placeholder' => 'parameters.typical.placeholder', 'placeholder' => 'parameters.typical.placeholder',
'class' => 'form-control-sm', 'class' => 'form-control-sm',
'style' => 'max-width: 12ch;', 'style' => 'max-width: 25ch;',
], ],
]); ]);
$builder->add('unit', TextType::class, [ $builder->add('unit', TextType::class, [

View file

@ -102,6 +102,8 @@ class PartBaseType extends AbstractType
'dto_value' => $dto?->category, 'dto_value' => $dto?->category,
'label' => 'part.edit.category', 'label' => 'part.edit.category',
'disable_not_selectable' => true, '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, [ ->add('footprint', StructuralEntityType::class, [
'class' => Footprint::class, 'class' => Footprint::class,

View file

@ -103,10 +103,12 @@ class PartLotType extends AbstractType
'help' => 'part_lot.owner.help', 'help' => 'part_lot.owner.help',
]); ]);
$builder->add('vendor_barcode', TextType::class, [ $builder->add('user_barcode', TextType::class, [
'label' => 'part_lot.edit.vendor_barcode', 'label' => 'part_lot.edit.user_barcode',
'help' => 'part_lot.edit.vendor_barcode.help', 'help' => 'part_lot.edit.vendor_barcode.help',
'required' => false, 'required' => false,
//Do not remove whitespace chars on the beginning and end of the string
'trim' => false,
]); ]);
} }

View file

@ -53,6 +53,7 @@ class TFAGoogleSettingsType extends AbstractType
'google_confirmation', 'google_confirmation',
TextType::class, TextType::class,
[ [
'label' => 'tfa.check.code.confirmation',
'mapped' => false, 'mapped' => false,
'attr' => [ 'attr' => [
'maxlength' => '6', 'maxlength' => '6',
@ -60,7 +61,7 @@ class TFAGoogleSettingsType extends AbstractType
'pattern' => '\d*', 'pattern' => '\d*',
'autocomplete' => 'off', 'autocomplete' => 'off',
], ],
'constraints' => [new ValidGoogleAuthCode()], 'constraints' => [new ValidGoogleAuthCode(groups: ["google_authenticator"])],
] ]
); );
@ -92,6 +93,7 @@ class TFAGoogleSettingsType extends AbstractType
{ {
$resolver->setDefaults([ $resolver->setDefaults([
'data_class' => User::class, 'data_class' => User::class,
'validation_groups' => ['google_authenticator'],
]); ]);
} }
} }

View file

@ -27,6 +27,7 @@ use App\Form\Type\Helper\ExponentialNumberTransformer;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\NumberType; use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface; 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 * Similar to the NumberType, but formats small values in scienfitic notation instead of rounding it to 0, like NumberType
@ -38,7 +39,15 @@ class ExponentialNumberType extends AbstractType
return NumberType::class; return NumberType::class;
} }
public function buildForm(FormBuilderInterface $builder, array $options) 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->resetViewTransformers();

View file

@ -23,21 +23,22 @@ declare(strict_types=1);
namespace App\Form\Type\Helper; namespace App\Form\Type\Helper;
use App\Form\Fixes\FixedNumberToLocalizedStringTransformer;
use Symfony\Component\Form\Exception\TransformationFailedException; 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 * This transformer formats small values in scienfitic notation instead of rounding it to 0, like the default
* NumberFormatter. * NumberFormatter.
*/ */
class ExponentialNumberTransformer extends FixedNumberToLocalizedStringTransformer class ExponentialNumberTransformer extends NumberToLocalizedStringTransformer
{ {
public function __construct( public function __construct(
protected ?int $scale = null, private ?int $scale = null,
?bool $grouping = false, ?bool $grouping = false,
?int $roundingMode = \NumberFormatter::ROUND_HALFUP, ?int $roundingMode = \NumberFormatter::ROUND_HALFUP,
protected ?string $locale = null protected ?string $locale = null
) { ) {
//Set scale to null, to disable rounding of values
parent::__construct($scale, $grouping, $roundingMode, $locale); parent::__construct($scale, $grouping, $roundingMode, $locale);
} }
@ -85,12 +86,28 @@ class ExponentialNumberTransformer extends FixedNumberToLocalizedStringTransform
$formatter = new \NumberFormatter($this->locale ?? \Locale::getDefault(), \NumberFormatter::SCIENTIFIC); $formatter = new \NumberFormatter($this->locale ?? \Locale::getDefault(), \NumberFormatter::SCIENTIFIC);
if (null !== $this->scale) { if (null !== $this->scale) {
$formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $this->scale); $formatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, $this->scale);
$formatter->setAttribute(\NumberFormatter::ROUNDING_MODE, $this->roundingMode); $formatter->setAttribute(\NumberFormatter::ROUNDING_MODE, $this->roundingMode);
} }
$formatter->setAttribute(\NumberFormatter::GROUPING_USED, (int) $this->grouping); $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; return $formatter;
} }
} }

View file

@ -43,7 +43,7 @@ class StructuralEntityChoiceHelper
/** /**
* Generates the choice attributes for the given AbstractStructuralDBElement. * Generates the choice attributes for the given AbstractStructuralDBElement.
* @return array|string[] * @return array<string, mixed>
*/ */
public function generateChoiceAttr(AbstractNamedDBElement $choice, Options|array $options): array public function generateChoiceAttr(AbstractNamedDBElement $choice, Options|array $options): array
{ {

Some files were not shown because too many files have changed in this diff Show more