Merge remote-tracking branch 'origin/l10n_master'

This commit is contained in:
Jan Böhmer 2024-11-18 15:43:10 +01:00
commit dd54c46a29
317 changed files with 37151 additions and 8990 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

@ -181,6 +181,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
################################################################################## ##################################################################################

View file

@ -1,22 +1,64 @@
FROM debian:bullseye-slim ARG BASE_IMAGE=debian:bookworm-slim
ARG PHP_VERSION=8.2
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 \ 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 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,86 @@ 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
echo 'realpath_cache_size=4096K'; \ realpath_cache_size=4096K
echo 'realpath_cache_ttl=600'; \ realpath_cache_ttl=600
} > /etc/php/8.1/fpm/conf.d/symfony-recommended.ini EOF
# 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'; \ EOF
} > /etc/php/8.1/fpm/conf.d/partdb.ini
# 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 +163,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"]

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 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; \
@ -17,6 +31,7 @@ RUN set -eux; \
zip \ zip \
pdo_mysql \ pdo_mysql \
pdo_sqlite \ pdo_sqlite \
pdo_pgsql \
gd \ gd \
bcmath \ bcmath \
xsl \ xsl \
@ -32,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; \
@ -57,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

View file

@ -1 +1 @@
1.13.0-dev 1.14.4

View file

@ -88,5 +88,8 @@ export default class extends Controller {
} else { } else {
this.hideSidebar(); this.hideSidebar();
} }
//Hide the tootip on the button
this._toggle_button.blur();
} }
} }

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

@ -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 {

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

@ -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

@ -41,8 +41,8 @@
"nyholm/psr7": "^1.1", "nyholm/psr7": "^1.1",
"ocramius/proxy-manager": "2.2.*", "ocramius/proxy-manager": "2.2.*",
"omines/datatables-bundle": "^0.8.0", "omines/datatables-bundle": "^0.8.0",
"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", "phpdocumentor/reflection-docblock": "^5.2",
"phpstan/phpdoc-parser": "^1.23", "phpstan/phpdoc-parser": "^1.23",
"runtime/frankenphp-symfony": "^0.2.0", "runtime/frankenphp-symfony": "^0.2.0",
@ -98,13 +98,14 @@
"dama/doctrine-test-bundle": "^v8.0.0", "dama/doctrine-test-bundle": "^v8.0.0",
"doctrine/doctrine-fixtures-bundle": "^3.2", "doctrine/doctrine-fixtures-bundle": "^3.2",
"ekino/phpstan-banned-code": "^v1.0.0", "ekino/phpstan-banned-code": "^v1.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": "^1.4.7",
"phpstan/phpstan-doctrine": "^1.2.11", "phpstan/phpstan-doctrine": "^1.2.11",
"phpstan/phpstan-strict-rules": "^1.5", "phpstan/phpstan-strict-rules": "^1.5",
"phpstan/phpstan-symfony": "^1.1.7", "phpstan/phpstan-symfony": "^1.1.7",
"phpunit/phpunit": "^9.5", "phpunit/phpunit": "^9.5",
"rector/rector": "^0.18.0", "rector/rector": "^1.1.1",
"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.*",

3001
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],
@ -32,4 +31,5 @@ return [
KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true], KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true],
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\TranslationEditorBundle\JbtronicsTranslationEditorBundle::class => ['dev' => true],
]; ];

View file

@ -10,13 +10,12 @@ datatables:
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: '%partdb.table.default_page_size%' # 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' B l> <'col mt-2' <'pull-right' p>>>" <'row' <'col mt-2 input-group flex-nowrap' B l > <'col-auto mt-2' < 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

@ -9,10 +9,17 @@ doctrine:
# either here or in the DATABASE_URL env var (see .env file) # either here or in the DATABASE_URL env var (see .env file)
types: types:
# UTC datetimes
datetime: datetime:
class: App\Doctrine\Types\UTCDateTimeType class: App\Doctrine\Types\UTCDateTimeType
date: date:
class: App\Doctrine\Types\UTCDateTimeType class: App\Doctrine\Types\UTCDateTimeType
datetime_immutable:
class: App\Doctrine\Types\UTCDateTimeImmutableType
date_immutable:
class: App\Doctrine\Types\UTCDateTimeImmutableType
big_decimal: big_decimal:
class: App\Doctrine\Types\BigDecimalType class: App\Doctrine\Types\BigDecimalType
tinyint: tinyint:

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

@ -11,7 +11,7 @@ parameters:
partdb.banner: '%env(trim:string:BANNER)%' # The info text shown in the homepage, if empty config/banner.md is used partdb.banner: '%env(trim:string:BANNER)%' # The info text shown in the homepage, if empty config/banner.md is used
partdb.default_currency: '%env(string:BASE_CURRENCY)%' # The currency that is used inside the DB (and is assumed when no currency is set). This can not be changed later, so be sure to set it the currency used in your country partdb.default_currency: '%env(string:BASE_CURRENCY)%' # The currency that is used inside the DB (and is assumed when no currency is set). This can not be changed later, so be sure to set it the currency used in your country
partdb.global_theme: '' # The theme to use globally (see public/build/themes/ for choices, use name without .css). Set to '' for default bootstrap theme partdb.global_theme: '' # The theme to use globally (see public/build/themes/ for choices, use name without .css). Set to '' for default bootstrap theme
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.enforce_change_comments_for: '%env(csv:ENFORCE_CHANGE_COMMENTS_FOR)%' # The actions for which a change comment is required (e.g. "part_edit", "part_create", etc.). If this is empty, change comments are not required at all. partdb.enforce_change_comments_for: '%env(csv:ENFORCE_CHANGE_COMMENTS_FOR)%' # The actions for which a change comment is required (e.g. "part_edit", "part_create", etc.). If this is empty, change comments are not required at all.
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

@ -76,18 +76,12 @@ services:
# Only the event classes specified here are saved to DB (set to []) to log all events # Only the event classes specified here are saved to DB (set to []) to log all events
$whitelist: [] $whitelist: []
App\EventSubscriber\LogSystem\EventLoggerSubscriber: App\EventListener\LogSystem\EventLoggerListener:
arguments: arguments:
$save_changed_fields: '%env(bool:HISTORY_SAVE_CHANGED_FIELDS)%' $save_changed_fields: '%env(bool:HISTORY_SAVE_CHANGED_FIELDS)%'
$save_changed_data: '%env(bool:HISTORY_SAVE_CHANGED_DATA)%' $save_changed_data: '%env(bool:HISTORY_SAVE_CHANGED_DATA)%'
$save_removed_data: '%env(bool:HISTORY_SAVE_REMOVED_DATA)%' $save_removed_data: '%env(bool:HISTORY_SAVE_REMOVED_DATA)%'
$save_new_data: '%env(bool:HISTORY_SAVE_NEW_DATA)%' $save_new_data: '%env(bool:HISTORY_SAVE_NEW_DATA)%'
tags:
- { name: 'doctrine.event_subscriber' }
App\EventSubscriber\LogSystem\LogDBMigrationSubscriber:
tags:
- { name: 'doctrine.event_subscriber' }
App\Form\AttachmentFormType: App\Form\AttachmentFormType:
arguments: arguments:
@ -312,6 +306,16 @@ services:
$enabled: '%env(bool:PROVIDER_LCSC_ENABLED)%' $enabled: '%env(bool:PROVIDER_LCSC_ENABLED)%'
$currency: '%env(string:PROVIDER_LCSC_CURRENCY)%' $currency: '%env(string:PROVIDER_LCSC_CURRENCY)%'
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
#################################################################################################################### ####################################################################################################################

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

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

@ -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

@ -343,6 +343,7 @@ final class Version20240606203053 extends AbstractMultiPlatformMigration impleme
$this->addSql('ALTER TABLE webauthn_keys CHANGE public_key_credential_id public_key_credential_id LONGTEXT NOT NULL, CHANGE transports transports LONGTEXT NOT NULL, CHANGE trust_path trust_path JSON NOT NULL, CHANGE aaguid aaguid TINYTEXT NOT NULL, CHANGE credential_public_key credential_public_key LONGTEXT NOT NULL, CHANGE other_ui other_ui LONGTEXT DEFAULT NULL, CHANGE last_time_used last_time_used DATETIME DEFAULT NULL'); $this->addSql('ALTER TABLE webauthn_keys CHANGE public_key_credential_id public_key_credential_id LONGTEXT NOT NULL, CHANGE transports transports LONGTEXT NOT NULL, CHANGE trust_path trust_path JSON NOT NULL, CHANGE aaguid aaguid TINYTEXT NOT NULL, CHANGE credential_public_key credential_public_key LONGTEXT NOT NULL, CHANGE other_ui other_ui LONGTEXT DEFAULT NULL, CHANGE last_time_used last_time_used DATETIME DEFAULT NULL');
// Add the natural sort emulation function to the database (based on this stackoverflow: https://stackoverflow.com/questions/153633/natural-sort-in-mysql/58154535#58154535) // Add the natural sort emulation function to the database (based on this stackoverflow: https://stackoverflow.com/questions/153633/natural-sort-in-mysql/58154535#58154535)
//This version here is wrong, and will be replaced by the correct version in the next migration (we need to use nowdoc instead of heredoc, otherwise the slashes will be wrongly escaped!!)
$this->addSql(<<<EOD $this->addSql(<<<EOD
CREATE DEFINER=CURRENT_USER FUNCTION `NatSortKey`(`s` VARCHAR(1000) CHARSET utf8mb4, `n` INT) RETURNS varchar(3500) CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci CREATE DEFINER=CURRENT_USER FUNCTION `NatSortKey`(`s` VARCHAR(1000) CHARSET utf8mb4, `n` INT) RETURNS varchar(3500) CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci
DETERMINISTIC DETERMINISTIC
@ -411,7 +412,7 @@ final class Version20240606203053 extends AbstractMultiPlatformMigration impleme
DECLARE x,y varchar(1000); # need to be same length as input s DECLARE x,y varchar(1000); # need to be same length as input s
DECLARE r varchar(3500) DEFAULT ''; # return value: needs to be same length as return type DECLARE r varchar(3500) DEFAULT ''; # return value: needs to be same length as return type
DECLARE suf varchar(101); # suffix for a number or version string. Must be (((inputlength+1) DIV 2)*2 + 1) chars to support version strings (e.g. '1.2.33.5'), though it's usually just 3 chars. (Max version string e.g. 1.2. ... .5 has ((length of input + 1) DIV 2) numeric components) DECLARE suf varchar(1001); # suffix for a number or version string. Must be (((inputlength+1) DIV 2)*2 + 1) chars to support version strings (e.g. '1.2.33.5'), though it's usually just 3 chars. (Max version string e.g. 1.2. ... .5 has ((length of input + 1) DIV 2) numeric components)
DECLARE i,j,k int UNSIGNED; DECLARE i,j,k int UNSIGNED;
IF n<=0 THEN SET n := -1; END IF; # n<=0 means "process all numbers" IF n<=0 THEN SET n := -1; END IF; # n<=0 means "process all numbers"
LOOP LOOP

View file

@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240728145604 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Update the Natural Sorting function for MySQL';
}
public function mySQLUp(Schema $schema): void
{
//Remove the old function
$this->addSql('DROP FUNCTION IF EXISTS NatSortKey');
//The difference to the original function is the correct length of the suf variable and correct escaping
//We now use heredoc syntax to avoid escaping issues with the \ (which resulted in "range out of order in character class").
$this->addSql(<<<'EOD'
CREATE DEFINER=CURRENT_USER FUNCTION `NatSortKey`(`s` VARCHAR(1000) CHARSET utf8mb4, `n` INT) RETURNS varchar(3500) CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci
DETERMINISTIC
SQL SECURITY INVOKER
BEGIN
/****
Converts numbers in the input string s into a format such that sorting results in a nat-sort.
Numbers of up to 359 digits (before the decimal point, if one is present) are supported. Sort results are undefined if the input string contains numbers longer than this.
For n>0, only the first n numbers in the input string will be converted for nat-sort (so strings that differ only after the first n numbers will not nat-sort amongst themselves).
Total sort-ordering is preserved, i.e. if s1!=s2, then NatSortKey(s1,n)!=NatSortKey(s2,n), for any given n.
Numbers may contain ',' as a thousands separator, and '.' as a decimal point. To reverse these (as appropriate for some European locales), the code would require modification.
Numbers preceded by '+' sort with numbers not preceded with either a '+' or '-' sign.
Negative numbers (preceded with '-') sort before positive numbers, but are sorted in order of ascending absolute value (so -7 sorts BEFORE -1001).
Numbers with leading zeros sort after the same number with no (or fewer) leading zeros.
Decimal-part-only numbers (like .75) are recognised, provided the decimal point is not immediately preceded by either another '.', or by a letter-type character.
Numbers with thousand separators sort after the same number without them.
Thousand separators are only recognised in numbers with no leading zeros that don't immediately follow a ',', and when they format the number correctly.
(When not recognised as a thousand separator, a ',' will instead be treated as separating two distinct numbers).
Version-number-like sequences consisting of 3 or more numbers separated by '.' are treated as distinct entities, and each component number will be nat-sorted.
The entire entity will sort after any number beginning with the first component (so e.g. 10.2.1 sorts after both 10 and 10.995, but before 11)
Note that The first number component in an entity like this is also permitted to contain thousand separators.
To achieve this, numbers within the input string are prefixed and suffixed according to the following format:
- The number is prefixed by a 2-digit base-36 number representing its length, excluding leading zeros. If there is a decimal point, this length only includes the integer part of the number.
- A 3-character suffix is appended after the number (after the decimals if present).
- The first character is a space, or a '+' sign if the number was preceded by '+'. Any preceding '+' sign is also removed from the front of the number.
- This is followed by a 2-digit base-36 number that encodes the number of leading zeros and whether the number was expressed in comma-separated form (e.g. 1,000,000.25 vs 1000000.25)
- The value of this 2-digit number is: (number of leading zeros)*2 + (1 if comma-separated, 0 otherwise)
- For version number sequences, each component number has the prefix in front of it, and the separating dots are removed.
Then there is a single suffix that consists of a ' ' or '+' character, followed by a pair base-36 digits for each number component in the sequence.
e.g. here is how some simple sample strings get converted:
'Foo055' --> 'Foo0255 02'
'Absolute zero is around -273 centigrade' --> 'Absolute zero is around -03273 00 centigrade'
'The $1,000,000 prize' --> 'The $071000000 01 prize'
'+99.74 degrees' --> '0299.74+00 degrees'
'I have 0 apples' --> 'I have 00 02 apples'
'.5 is the same value as 0000.5000' --> '00.5 00 is the same value as 00.5000 08'
'MariaDB v10.3.0018' --> 'MariaDB v02100130218 000004'
The restriction to numbers of up to 359 digits comes from the fact that the first character of the base-36 prefix MUST be a decimal digit, and so the highest permitted prefix value is '9Z' or 359 decimal.
The code could be modified to handle longer numbers by increasing the size of (both) the prefix and suffix.
A higher base could also be used (by replacing CONV() with a custom function), provided that the collation you are using sorts the "digits" of the base in the correct order, starting with 0123456789.
However, while the maximum number length may be increased this way, note that the technique this function uses is NOT applicable where strings may contain numbers of unlimited length.
The function definition does not specify the charset or collation to be used for string-type parameters or variables: The default database charset & collation at the time the function is defined will be used.
This is to make the function code more portable. However, there are some important restrictions:
- Collation is important here only when comparing (or storing) the output value from this function, but it MUST order the characters " +0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" in that order for the natural sort to work.
This is true for most collations, but not all of them, e.g. in Lithuanian 'Y' comes before 'J' (according to Wikipedia).
To adapt the function to work with such collations, replace CONV() in the function code with a custom function that emits "digits" above 9 that are characters ordered according to the collation in use.
- For efficiency, the function code uses LENGTH() rather than CHAR_LENGTH() to measure the length of strings that consist only of digits 0-9, '.', and ',' characters.
This works for any single-byte charset, as well as any charset that maps standard ASCII characters to single bytes (such as utf8 or utf8mb4).
If using a charset that maps these characters to multiple bytes (such as, e.g. utf16 or utf32), you MUST replace all instances of LENGTH() in the function definition with CHAR_LENGTH()
Length of the output:
Each number converted adds 5 characters (2 prefix + 3 suffix) to the length of the string. n is the maximum count of numbers to convert;
This parameter is provided as a means to limit the maximum output length (to input length + 5*n).
If you do not require the total-ordering property, you could edit the code to use suffixes of 1 character (space or plus) only; this would reduce the maximum output length for any given n.
Since a string of length L has at most ((L+1) DIV 2) individual numbers in it (every 2nd character a digit), for n<=0 the maximum output length is (inputlength + 5*((inputlength+1) DIV 2))
So for the current input length of 100, the maximum output length is 350.
If changing the input length, the output length must be modified according to the above formula. The DECLARE statements for x,y,r, and suf must also be modified, as the code comments indicate.
****/
DECLARE x,y varchar(1000); # need to be same length as input s
DECLARE r varchar(3500) DEFAULT ''; # return value: needs to be same length as return type
DECLARE suf varchar(1001); # suffix for a number or version string. Must be (((inputlength+1) DIV 2)*2 + 1) chars to support version strings (e.g. '1.2.33.5'), though it's usually just 3 chars. (Max version string e.g. 1.2. ... .5 has ((length of input + 1) DIV 2) numeric components)
DECLARE i,j,k int UNSIGNED;
IF n<=0 THEN SET n := -1; END IF; # n<=0 means "process all numbers"
LOOP
SET i := REGEXP_INSTR(s,'\\d'); # find position of next digit
IF i=0 OR n=0 THEN RETURN CONCAT(r,s); END IF; # no more numbers to process -> we're done
SET n := n-1, suf := ' ';
IF i>1 THEN
IF SUBSTRING(s,i-1,1)='.' AND (i=2 OR SUBSTRING(s,i-2,1) RLIKE '[^.\\p{L}\\p{N}\\p{M}\\x{608}\\x{200C}\\x{200D}\\x{2100}-\\x{214F}\\x{24B6}-\\x{24E9}\\x{1F130}-\\x{1F149}\\x{1F150}-\\x{1F169}\\x{1F170}-\\x{1F189}]') AND (SUBSTRING(s,i) NOT RLIKE '^\\d++\\.\\d') THEN SET i:=i-1; END IF; # Allow decimal number (but not version string) to begin with a '.', provided preceding char is neither another '.', nor a member of the unicode character classes: "Alphabetic", "Letter", "Block=Letterlike Symbols" "Number", "Mark", "Join_Control"
IF i>1 AND SUBSTRING(s,i-1,1)='+' THEN SET suf := '+', j := i-1; ELSE SET j := i; END IF; # move any preceding '+' into the suffix, so equal numbers with and without preceding "+" signs sort together
SET r := CONCAT(r,SUBSTRING(s,1,j-1)); SET s = SUBSTRING(s,i); # add everything before the number to r and strip it from the start of s; preceding '+' is dropped (not included in either r or s)
END IF;
SET x := REGEXP_SUBSTR(s,IF(SUBSTRING(s,1,1) IN ('0','.') OR (SUBSTRING(r,-1)=',' AND suf=' '),'^\\d*+(?:\\.\\d++)*','^(?:[1-9]\\d{0,2}(?:,\\d{3}(?!\\d))++|\\d++)(?:\\.\\d++)*+')); # capture the number + following decimals (including multiple consecutive '.<digits>' sequences)
SET s := SUBSTRING(s,CHAR_LENGTH(x)+1); # NOTE: CHAR_LENGTH() can be safely used instead of CHAR_LENGTH() here & below PROVIDED we're using a charset that represents digits, ',' and '.' characters using single bytes (e.g. latin1, utf8)
SET i := INSTR(x,'.');
IF i=0 THEN SET y := ''; ELSE SET y := SUBSTRING(x,i); SET x := SUBSTRING(x,1,i-1); END IF; # move any following decimals into y
SET i := CHAR_LENGTH(x);
SET x := REPLACE(x,',','');
SET j := CHAR_LENGTH(x);
SET x := TRIM(LEADING '0' FROM x); # strip leading zeros
SET k := CHAR_LENGTH(x);
SET suf := CONCAT(suf,LPAD(CONV(LEAST((j-k)*2,1294) + IF(i=j,0,1),10,36),2,'0')); # (j-k)*2 + IF(i=j,0,1) = (count of leading zeros)*2 + (1 if there are thousands-separators, 0 otherwise) Note the first term is bounded to <= base-36 'ZY' as it must fit within 2 characters
SET i := LOCATE('.',y,2);
IF i=0 THEN
SET r := CONCAT(r,LPAD(CONV(LEAST(k,359),10,36),2,'0'),x,y,suf); # k = count of digits in number, bounded to be <= '9Z' base-36
ELSE # encode a version number (like 3.12.707, etc)
SET r := CONCAT(r,LPAD(CONV(LEAST(k,359),10,36),2,'0'),x); # k = count of digits in number, bounded to be <= '9Z' base-36
WHILE CHAR_LENGTH(y)>0 AND n!=0 DO
IF i=0 THEN SET x := SUBSTRING(y,2); SET y := ''; ELSE SET x := SUBSTRING(y,2,i-2); SET y := SUBSTRING(y,i); SET i := LOCATE('.',y,2); END IF;
SET j := CHAR_LENGTH(x);
SET x := TRIM(LEADING '0' FROM x); # strip leading zeros
SET k := CHAR_LENGTH(x);
SET r := CONCAT(r,LPAD(CONV(LEAST(k,359),10,36),2,'0'),x); # k = count of digits in number, bounded to be <= '9Z' base-36
SET suf := CONCAT(suf,LPAD(CONV(LEAST((j-k)*2,1294),10,36),2,'0')); # (j-k)*2 = (count of leading zeros)*2, bounded to fit within 2 base-36 digits
SET n := n-1;
END WHILE;
SET r := CONCAT(r,y,suf);
END IF;
END LOOP;
END
EOD
);
}
public function mySQLDown(Schema $schema): void
{
//Not needed
}
public function sqLiteUp(Schema $schema): void
{
//Not needed
}
public function sqLiteDown(Schema $schema): void
{
//Not needed
}
public function postgreSQLUp(Schema $schema): void
{
//Not needed
}
public function postgreSQLDown(Schema $schema): void
{
//Not needed
}
}

View file

@ -11,6 +11,8 @@ parameters:
- src/Configuration/* - src/Configuration/*
- src/Doctrine/Purger/* - src/Doctrine/Purger/*
- src/DataTables/Adapters/TwoStepORMAdapter.php - src/DataTables/Adapters/TwoStepORMAdapter.php
- src/Form/Fixes/*
- src/Translation/Fixes/*

View file

@ -2,12 +2,17 @@
declare(strict_types=1); declare(strict_types=1);
use Rector\CodeQuality\Rector\Identical\FlipTypeControlToUseExclusiveTypeRector;
use Rector\CodingStyle\Rector\FuncCall\CountArrayToEmptyArrayComparisonRector; use Rector\CodingStyle\Rector\FuncCall\CountArrayToEmptyArrayComparisonRector;
use Rector\Config\RectorConfig; use Rector\Config\RectorConfig;
use Rector\Doctrine\Set\DoctrineSetList; use Rector\Doctrine\Set\DoctrineSetList;
use Rector\PHPUnit\CodeQuality\Rector\Class_\PreferPHPUnitThisCallRector;
use Rector\PHPUnit\Set\PHPUnitSetList; use Rector\PHPUnit\Set\PHPUnitSetList;
use Rector\Set\ValueObject\LevelSetList; use Rector\Set\ValueObject\LevelSetList;
use Rector\Set\ValueObject\SetList; use Rector\Set\ValueObject\SetList;
use Rector\Symfony\CodeQuality\Rector\Class_\EventListenerToEventSubscriberRector;
use Rector\Symfony\CodeQuality\Rector\ClassMethod\ActionSuffixRemoverRector;
use Rector\Symfony\CodeQuality\Rector\MethodCall\LiteralGetToRequestClassConstantRector;
use Rector\Symfony\Set\SymfonySetList; use Rector\Symfony\Set\SymfonySetList;
use Rector\TypeDeclaration\Rector\StmtsAwareInterface\DeclareStrictTypesRector; use Rector\TypeDeclaration\Rector\StmtsAwareInterface\DeclareStrictTypesRector;
@ -55,5 +60,22 @@ return static function (RectorConfig $rectorConfig): void {
$rectorConfig->skip([ $rectorConfig->skip([
CountArrayToEmptyArrayComparisonRector::class, CountArrayToEmptyArrayComparisonRector::class,
//Leave our !== null checks alone
FlipTypeControlToUseExclusiveTypeRector::class,
//Leave our PartList TableAction alone
ActionSuffixRemoverRector::class,
//We declare event listeners via attributes, therefore no need to migrate them to subscribers
EventListenerToEventSubscriberRector::class,
PreferPHPUnitThisCallRector::class,
//Do not replace 'GET' with class constant,
LiteralGetToRequestClassConstantRector::class,
]);
//Do not apply rules to Symfony own files
$rectorConfig->skip([
__DIR__ . '/public/index.php',
__DIR__ . '/src/Kernel.php',
__DIR__ . '/config/preload.php',
__DIR__ . '/config/bundles.php',
]); ]);
}; };

View file

@ -81,12 +81,12 @@ class EntityFilterHelper
public function getDescription(array $properties): array public function getDescription(array $properties): array
{ {
if (!$properties) { if ($properties === []) {
return []; return [];
} }
$description = []; $description = [];
foreach ($properties as $property => $strategy) { foreach (array_keys($properties) as $property) {
$description[(string)$property] = [ $description[(string)$property] = [
'property' => $property, 'property' => $property,
'type' => Type::BUILTIN_TYPE_STRING, 'type' => Type::BUILTIN_TYPE_STRING,

View file

@ -61,7 +61,7 @@ final class LikeFilter extends AbstractFilter
} }
$description = []; $description = [];
foreach ($this->properties as $property => $strategy) { foreach (array_keys($this->properties) as $property) {
$description[(string)$property] = [ $description[(string)$property] = [
'property' => $property, 'property' => $property,
'type' => Type::BUILTIN_TYPE_STRING, 'type' => Type::BUILTIN_TYPE_STRING,

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(
$expr->like('o.'.$property, ':' . $tag_identifier_prefix . '_1'),
$expr->like('o.'.$property, ':' . $tag_identifier_prefix . '_2'),
$expr->like('o.'.$property, ':' . $tag_identifier_prefix . '_3'),
$expr->eq('o.'.$property, ':' . $tag_identifier_prefix . '_4'),
);
$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

@ -52,6 +52,7 @@ class BackupCommand extends Command
$backup_attachments = $input->getOption('attachments'); $backup_attachments = $input->getOption('attachments');
$backup_config = $input->getOption('config'); $backup_config = $input->getOption('config');
$backup_full = $input->getOption('full'); $backup_full = $input->getOption('full');
$overwrite = $input->getOption('overwrite');
if ($backup_full) { if ($backup_full) {
$backup_database = true; $backup_database = true;
@ -70,7 +71,9 @@ class BackupCommand extends Command
//Check if the file already exists //Check if the file already exists
//Then ask the user, if he wants to overwrite the file //Then ask the user, if he wants to overwrite the file
if (file_exists($output_filepath) && !$io->confirm('The file '.realpath($output_filepath).' already exists. Do you want to overwrite it?', false)) { if (!$overwrite
&& file_exists($output_filepath)
&& !$io->confirm('The file '.realpath($output_filepath).' already exists. Do you want to overwrite it?', false)) {
$io->error('Backup aborted!'); $io->error('Backup aborted!');
return Command::FAILURE; return Command::FAILURE;
} }

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

@ -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;
@ -52,8 +53,8 @@ use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; 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\EventDispatcher;
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;
@ -61,6 +62,8 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Validator\ConstraintViolationInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t; use function Symfony\Component\Translation\t;
@ -74,15 +77,10 @@ abstract class BaseAdminController extends AbstractController
protected string $attachment_class = ''; protected string $attachment_class = '';
protected ?string $parameter_class = ''; protected ?string $parameter_class = '';
/**
* @var EventDispatcher|EventDispatcherInterface
*/
protected EventDispatcher|EventDispatcherInterface $eventDispatcher;
public function __construct(protected TranslatorInterface $translator, protected UserPasswordHasherInterface $passwordEncoder, public function __construct(protected TranslatorInterface $translator, protected UserPasswordHasherInterface $passwordEncoder,
protected AttachmentSubmitHandler $attachmentSubmitHandler, protected AttachmentSubmitHandler $attachmentSubmitHandler,
protected EventCommentHelper $commentHelper, protected HistoryHelper $historyHelper, protected TimeTravel $timeTravel, protected EventCommentHelper $commentHelper, protected HistoryHelper $historyHelper, protected TimeTravel $timeTravel,
protected DataTableFactory $dataTableFactory, EventDispatcherInterface $eventDispatcher, protected LabelExampleElementsGenerator $barcodeExampleGenerator, protected DataTableFactory $dataTableFactory, protected EventDispatcherInterface $eventDispatcher, protected LabelExampleElementsGenerator $barcodeExampleGenerator,
protected LabelGenerator $labelGenerator, protected EntityManagerInterface $entityManager) protected LabelGenerator $labelGenerator, protected EntityManagerInterface $entityManager)
{ {
if ('' === $this->entity_class || '' === $this->form_class || '' === $this->twig_template || '' === $this->route_base) { if ('' === $this->entity_class || '' === $this->form_class || '' === $this->twig_template || '' === $this->route_base) {
@ -96,7 +94,6 @@ abstract class BaseAdminController extends AbstractController
if ('' === $this->parameter_class || ($this->parameter_class && !is_a($this->parameter_class, AbstractParameter::class, true))) { if ('' === $this->parameter_class || ($this->parameter_class && !is_a($this->parameter_class, AbstractParameter::class, true))) {
throw new InvalidArgumentException('You have to override the $parameter_class value with a valid Parameter class in your subclass!'); throw new InvalidArgumentException('You have to override the $parameter_class value with a valid Parameter class in your subclass!');
} }
$this->eventDispatcher = $eventDispatcher;
} }
protected function revertElementIfNeeded(AbstractDBElement $entity, ?string $timestamp): ?DateTime protected function revertElementIfNeeded(AbstractDBElement $entity, ?string $timestamp): ?DateTime
@ -192,11 +189,9 @@ abstract class BaseAdminController extends AbstractController
} }
//Ensure that the master picture is still part of the attachments //Ensure that the master picture is still part of the attachments
if ($entity instanceof AttachmentContainingDBElement) { if ($entity instanceof AttachmentContainingDBElement && ($entity->getMasterPictureAttachment() !== null && !$entity->getAttachments()->contains($entity->getMasterPictureAttachment()))) {
if ($entity->getMasterPictureAttachment() !== null && !$entity->getAttachments()->contains($entity->getMasterPictureAttachment())) {
$entity->setMasterPictureAttachment(null); $entity->setMasterPictureAttachment(null);
} }
}
$this->commentHelper->setMessage($form['log_comment']->getData()); $this->commentHelper->setMessage($form['log_comment']->getData());
@ -218,7 +213,12 @@ 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 = null;
try {
$pdf_data = $this->labelGenerator->generateLabel($entity->getOptions(), $example); $pdf_data = $this->labelGenerator->generateLabel($entity->getOptions(), $example);
} catch (TwigModeException $exception) {
$form->get('options')->get('lines')->addError(new FormError($exception->getSafeMessage()));
}
} }
/** @var AbstractPartsContainingRepository $repo */ /** @var AbstractPartsContainingRepository $repo */
@ -283,11 +283,9 @@ abstract class BaseAdminController extends AbstractController
} }
//Ensure that the master picture is still part of the attachments //Ensure that the master picture is still part of the attachments
if ($new_entity instanceof AttachmentContainingDBElement) { if ($new_entity instanceof AttachmentContainingDBElement && ($new_entity->getMasterPictureAttachment() !== null && !$new_entity->getAttachments()->contains($new_entity->getMasterPictureAttachment()))) {
if ($new_entity->getMasterPictureAttachment() !== null && !$new_entity->getAttachments()->contains($new_entity->getMasterPictureAttachment())) {
$new_entity->setMasterPictureAttachment(null); $new_entity->setMasterPictureAttachment(null);
} }
}
$this->commentHelper->setMessage($form['log_comment']->getData()); $this->commentHelper->setMessage($form['log_comment']->getData());
$em->persist($new_entity); $em->persist($new_entity);
@ -333,8 +331,8 @@ abstract class BaseAdminController extends AbstractController
try { try {
$errors = $importer->importFileAndPersistToDB($file, $options); $errors = $importer->importFileAndPersistToDB($file, $options);
foreach ($errors as $name => $error) { foreach ($errors as $name => ['violations' => $violations]) {
foreach ($error as $violation) { foreach ($violations as $violation) {
$this->addFlash('error', $name.': '.$violation->getMessage()); $this->addFlash('error', $name.': '.$violation->getMessage());
} }
} }
@ -344,6 +342,7 @@ abstract class BaseAdminController extends AbstractController
} }
} }
ret:
//Mass creation form //Mass creation form
$mass_creation_form = $this->createForm(MassCreationForm::class, ['entity_class' => $this->entity_class]); $mass_creation_form = $this->createForm(MassCreationForm::class, ['entity_class' => $this->entity_class]);
$mass_creation_form->handleRequest($request); $mass_creation_form->handleRequest($request);
@ -356,11 +355,14 @@ abstract class BaseAdminController extends AbstractController
$results = $importer->massCreation($data['lines'], $this->entity_class, $data['parent'] ?? null, $errors); $results = $importer->massCreation($data['lines'], $this->entity_class, $data['parent'] ?? null, $errors);
//Show errors to user: //Show errors to user:
foreach ($errors as $error) { foreach ($errors as ['entity' => $new_entity, 'violations' => $violations]) {
if ($error['entity'] instanceof AbstractStructuralDBElement) { /** @var ConstraintViolationInterface $violation */
$this->addFlash('error', $error['entity']->getFullPath().':'.$error['violations']); foreach ($violations as $violation) {
if ($new_entity instanceof AbstractStructuralDBElement) {
$this->addFlash('error', $new_entity->getFullPath().':'.$violation->getMessage());
} else { //When we don't have a structural element, we can only show the name } else { //When we don't have a structural element, we can only show the name
$this->addFlash('error', $error['entity']->getName().':'.$error['violations']); $this->addFlash('error', $new_entity->getName().':'.$violation->getMessage());
}
} }
} }
@ -375,7 +377,6 @@ abstract class BaseAdminController extends AbstractController
} }
} }
ret:
return $this->render($this->twig_template, [ return $this->render($this->twig_template, [
'entity' => $new_entity, 'entity' => $new_entity,
'form' => $form, 'form' => $form,

View file

@ -52,11 +52,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);
@ -81,11 +81,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

@ -30,6 +30,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
/**
* @see \App\Tests\Controller\KiCadApiControllerTest
*/
#[Route('/kicad-api/v1')] #[Route('/kicad-api/v1')]
class KiCadApiController extends AbstractController class KiCadApiController extends AbstractController
{ {
@ -62,7 +65,7 @@ class KiCadApiController extends AbstractController
#[Route('/parts/category/{category}.json', name: 'kicad_api_category')] #[Route('/parts/category/{category}.json', name: 'kicad_api_category')]
public function categoryParts(?Category $category): Response public function categoryParts(?Category $category): Response
{ {
if ($category) { if ($category !== null) {
$this->denyAccessUnlessGranted('read', $category); $this->denyAccessUnlessGranted('read', $category);
} else { } else {
$this->denyAccessUnlessGranted('@categories.read'); $this->denyAccessUnlessGranted('@categories.read');

View file

@ -117,7 +117,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');

View file

@ -51,7 +51,7 @@ class OAuthClientController extends AbstractController
} }
#[Route('/{name}/check', name: 'oauth_client_check')] #[Route('/{name}/check', name: 'oauth_client_check')]
public function check(string $name, Request $request): Response public function check(string $name): Response
{ {
$this->denyAccessUnlessGranted('@system.manage_oauth_tokens'); $this->denyAccessUnlessGranted('@system.manage_oauth_tokens');

View file

@ -219,7 +219,7 @@ class ProjectController extends AbstractController
'project' => $project, 'project' => $project,
'part' => $part 'part' => $part
]); ]);
if ($bom_entry) { if ($bom_entry !== null) {
$preset_data->add($bom_entry); $preset_data->add($bom_entry);
} else { //Otherwise create an empty one } else { //Otherwise create an empty one
$entry = new ProjectBOMEntry(); $entry = new ProjectBOMEntry();

View file

@ -54,6 +54,9 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
/**
* @see \App\Tests\Controller\ScanControllerTest
*/
#[Route(path: '/scan')] #[Route(path: '/scan')]
class ScanController extends AbstractController class ScanController extends AbstractController
{ {

View file

@ -25,12 +25,14 @@ namespace App\Controller;
use App\Services\Attachments\AttachmentSubmitHandler; use App\Services\Attachments\AttachmentSubmitHandler;
use App\Services\Attachments\AttachmentURLGenerator; use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Attachments\BuiltinAttachmentsFinder; use App\Services\Attachments\BuiltinAttachmentsFinder;
use App\Services\Doctrine\DBInfoHelper;
use App\Services\Doctrine\NatsortDebugHelper;
use App\Services\Misc\GitVersionInfo; use App\Services\Misc\GitVersionInfo;
use App\Services\Misc\DBInfoHelper;
use App\Services\System\UpdateAvailableManager; use App\Services\System\UpdateAvailableManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Runtime\SymfonyRuntime;
#[Route(path: '/tools')] #[Route(path: '/tools')]
class ToolsController extends AbstractController class ToolsController extends AbstractController
@ -44,7 +46,7 @@ class ToolsController extends AbstractController
} }
#[Route(path: '/server_infos', name: 'tools_server_infos')] #[Route(path: '/server_infos', name: 'tools_server_infos')]
public function systemInfos(GitVersionInfo $versionInfo, DBInfoHelper $DBInfoHelper, public function systemInfos(GitVersionInfo $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper,
AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableManager $updateAvailableManager): Response AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableManager $updateAvailableManager): Response
{ {
$this->denyAccessUnlessGranted('@system.server_infos'); $this->denyAccessUnlessGranted('@system.server_infos');
@ -59,10 +61,10 @@ class ToolsController extends AbstractController
'default_theme' => $this->getParameter('partdb.global_theme'), 'default_theme' => $this->getParameter('partdb.global_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'), 'gdpr_compliance' => $this->getParameter('partdb.gdpr_compliance'),
'use_gravatar' => $this->getParameter('partdb.users.use_gravatar'), 'use_gravatar' => $this->getParameter('partdb.users.use_gravatar'),
'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'),
@ -84,7 +86,7 @@ class ToolsController extends AbstractController
'php_post_max_size' => ini_get('post_max_size'), 'php_post_max_size' => ini_get('post_max_size'),
'kernel_runtime_environment' => $this->getParameter('kernel.runtime_environment'), 'kernel_runtime_environment' => $this->getParameter('kernel.runtime_environment'),
'kernel_runtime_mode' => $this->getParameter('kernel.runtime_mode'), 'kernel_runtime_mode' => $this->getParameter('kernel.runtime_mode'),
'kernel_runtime' => $_SERVER['APP_RUNTIME'] ?? $_ENV['APP_RUNTIME'] ?? 'Symfony\\Component\\Runtime\\SymfonyRuntime', 'kernel_runtime' => $_SERVER['APP_RUNTIME'] ?? $_ENV['APP_RUNTIME'] ?? SymfonyRuntime::class,
//DB section //DB section
'db_type' => $DBInfoHelper->getDatabaseType() ?? 'Unknown', 'db_type' => $DBInfoHelper->getDatabaseType() ?? 'Unknown',
@ -92,6 +94,8 @@ class ToolsController extends AbstractController
'db_size' => $DBInfoHelper->getDatabaseSize(), 'db_size' => $DBInfoHelper->getDatabaseSize(),
'db_name' => $DBInfoHelper->getDatabaseName() ?? 'Unknown', 'db_name' => $DBInfoHelper->getDatabaseName() ?? 'Unknown',
'db_user' => $DBInfoHelper->getDatabaseUsername() ?? 'Unknown', 'db_user' => $DBInfoHelper->getDatabaseUsername() ?? 'Unknown',
'db_natsort_method' => $natsortDebugHelper->getNaturalSortMethod(),
'db_natsort_slow_allowed' => $natsortDebugHelper->isSlowNaturalSortAllowed(),
//New version section //New version section
'new_version_available' => $updateAvailableManager->isUpdateAvailable(), 'new_version_available' => $updateAvailableManager->isUpdateAvailable(),

View file

@ -38,7 +38,6 @@ use Doctrine\ORM\EntityManagerInterface;
use RuntimeException; use RuntimeException;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Google\GoogleAuthenticatorInterface; use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Google\GoogleAuthenticatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\EnumType;
@ -59,11 +58,8 @@ use Symfony\Component\Validator\Constraints\Length;
#[Route(path: '/user')] #[Route(path: '/user')]
class UserSettingsController extends AbstractController class UserSettingsController extends AbstractController
{ {
protected EventDispatcher|EventDispatcherInterface $eventDispatcher; public function __construct(protected bool $demo_mode, protected EventDispatcherInterface $eventDispatcher)
public function __construct(protected bool $demo_mode, EventDispatcherInterface $eventDispatcher)
{ {
$this->eventDispatcher = $eventDispatcher;
} }
#[Route(path: '/2fa_backup_codes', name: 'show_backup_codes')] #[Route(path: '/2fa_backup_codes', name: 'show_backup_codes')]

View file

@ -75,7 +75,7 @@ class APITokenFixtures extends Fixture implements DependentFixtureInterface
$expired_token->setUser($admin_user); $expired_token->setUser($admin_user);
$expired_token->setLevel(ApiTokenLevel::FULL); $expired_token->setLevel(ApiTokenLevel::FULL);
$expired_token->setName('expired'); $expired_token->setName('expired');
$expired_token->setValidUntil(new \DateTime('-1 day')); $expired_token->setValidUntil(new \DateTimeImmutable('-1 day'));
$this->setTokenSecret($expired_token, self::TOKEN_EXPIRED); $this->setTokenSecret($expired_token, self::TOKEN_EXPIRED);
$manager->persist($expired_token); $manager->persist($expired_token);

View file

@ -74,30 +74,37 @@ class DataStructureFixtures extends Fixture implements DependentFixtureInterface
/** @var AbstractStructuralDBElement $node1 */ /** @var AbstractStructuralDBElement $node1 */
$node1 = new $class(); $node1 = new $class();
$node1->setName('Node 1'); $node1->setName('Node 1');
$this->addReference($class . '_1', $node1);
/** @var AbstractStructuralDBElement $node2 */ /** @var AbstractStructuralDBElement $node2 */
$node2 = new $class(); $node2 = new $class();
$node2->setName('Node 2'); $node2->setName('Node 2');
$this->addReference($class . '_2', $node2);
/** @var AbstractStructuralDBElement $node3 */ /** @var AbstractStructuralDBElement $node3 */
$node3 = new $class(); $node3 = new $class();
$node3->setName('Node 3'); $node3->setName('Node 3');
$this->addReference($class . '_3', $node3);
$node1_1 = new $class(); $node1_1 = new $class();
$node1_1->setName('Node 1.1'); $node1_1->setName('Node 1.1');
$node1_1->setParent($node1); $node1_1->setParent($node1);
$this->addReference($class . '_4', $node1_1);
$node1_2 = new $class(); $node1_2 = new $class();
$node1_2->setName('Node 1.2'); $node1_2->setName('Node 1.2');
$node1_2->setParent($node1); $node1_2->setParent($node1);
$this->addReference($class . '_5', $node1_2);
$node2_1 = new $class(); $node2_1 = new $class();
$node2_1->setName('Node 2.1'); $node2_1->setName('Node 2.1');
$node2_1->setParent($node2); $node2_1->setParent($node2);
$this->addReference($class . '_6', $node2_1);
$node1_1_1 = new $class(); $node1_1_1 = new $class();
$node1_1_1->setName('Node 1.1.1'); $node1_1_1->setName('Node 1.1.1');
$node1_1_1->setParent($node1_1); $node1_1_1->setParent($node1_1);
$this->addReference($class . '_7', $node1_1_1);
$manager->persist($node1); $manager->persist($node1);
$manager->persist($node2); $manager->persist($node2);

View file

@ -0,0 +1,106 @@
<?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\DataFixtures;
use App\Entity\LogSystem\ElementCreatedLogEntry;
use App\Entity\LogSystem\ElementDeletedLogEntry;
use App\Entity\LogSystem\ElementEditedLogEntry;
use App\Entity\Parts\Category;
use App\Entity\UserSystem\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
class LogEntryFixtures extends Fixture implements DependentFixtureInterface
{
public function load(ObjectManager $manager)
{
$this->createCategoryEntries($manager);
$this->createDeletedCategory($manager);
}
public function createCategoryEntries(ObjectManager $manager): void
{
$category = $this->getReference(Category::class . '_1', Category::class);
$logEntry = new ElementCreatedLogEntry($category);
$logEntry->setTimestamp(new \DateTimeImmutable("+1 second"));
$logEntry->setUser($this->getReference(UserFixtures::ADMIN, User::class));
$logEntry->setComment('Test');
$manager->persist($logEntry);
$logEntry = new ElementEditedLogEntry($category);
$logEntry->setTimestamp(new \DateTimeImmutable("+2 second"));
$logEntry->setUser($this->getReference(UserFixtures::ADMIN, User::class));
$logEntry->setComment('Test');
$logEntry->setOldData(['name' => 'Test']);
$logEntry->setNewData(['name' => 'Node 1.1']);
$manager->persist($logEntry);
$manager->flush();
}
public function createDeletedCategory(ObjectManager $manager): void
{
//We create a fictive category to test the deletion
$category = new Category();
$category->setName('Node 100');
//Assume a category with id 100 was deleted
$reflClass = new \ReflectionClass($category);
$reflClass->getProperty('id')->setValue($category, 100);
//The whole lifecycle from creation to deletion
$logEntry = new ElementCreatedLogEntry($category);
$logEntry->setUser($this->getReference(UserFixtures::ADMIN, User::class));
$logEntry->setComment('Creation');
$manager->persist($logEntry);
$logEntry = new ElementEditedLogEntry($category);
$logEntry->setTimestamp(new \DateTimeImmutable("+1 second"));
$logEntry->setUser($this->getReference(UserFixtures::ADMIN, User::class));
$logEntry->setComment('Edit');
$logEntry->setOldData(['name' => 'Test']);
$logEntry->setNewData(['name' => 'Node 100']);
$manager->persist($logEntry);
$logEntry = new ElementDeletedLogEntry($category);
$logEntry->setTimestamp(new \DateTimeImmutable("+2 second"));
$logEntry->setUser($this->getReference(UserFixtures::ADMIN, User::class));
$logEntry->setOldData(['name' => 'Node 100', 'id' => 100, 'comment' => 'Test comment']);
$manager->persist($logEntry);
$manager->flush();
}
public function getDependencies(): array
{
return [
UserFixtures::class,
DataStructureFixtures::class
];
}
}

View file

@ -73,6 +73,7 @@ class PartFixtures extends Fixture implements DependentFixtureInterface
$part = new Part(); $part = new Part();
$part->setName('Part 1'); $part->setName('Part 1');
$part->setCategory($manager->find(Category::class, 1)); $part->setCategory($manager->find(Category::class, 1));
$this->addReference(Part::class . '_1', $part);
$manager->persist($part); $manager->persist($part);
/** More complex part */ /** More complex part */
@ -86,6 +87,7 @@ class PartFixtures extends Fixture implements DependentFixtureInterface
$part->setIpn('IPN123'); $part->setIpn('IPN123');
$part->setNeedsReview(true); $part->setNeedsReview(true);
$part->setManufacturingStatus(ManufacturingStatus::ACTIVE); $part->setManufacturingStatus(ManufacturingStatus::ACTIVE);
$this->addReference(Part::class . '_2', $part);
$manager->persist($part); $manager->persist($part);
/** Part with orderdetails, storelocations and Attachments */ /** Part with orderdetails, storelocations and Attachments */
@ -98,8 +100,9 @@ class PartFixtures extends Fixture implements DependentFixtureInterface
$partLot1->setStorageLocation($manager->find(StorageLocation::class, 1)); $partLot1->setStorageLocation($manager->find(StorageLocation::class, 1));
$part->addPartLot($partLot1); $part->addPartLot($partLot1);
$partLot2 = new PartLot(); $partLot2 = new PartLot();
$partLot2->setExpirationDate(new DateTime()); $partLot2->setExpirationDate(new \DateTimeImmutable());
$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));
@ -133,6 +136,8 @@ class PartFixtures extends Fixture implements DependentFixtureInterface
$attachment->setAttachmentType($manager->find(AttachmentType::class, 1)); $attachment->setAttachmentType($manager->find(AttachmentType::class, 1));
$part->addAttachment($attachment); $part->addAttachment($attachment);
$this->addReference(Part::class . '_3', $part);
$manager->persist($part); $manager->persist($part);
$manager->flush(); $manager->flush();
} }

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\DataTables\Adapters; namespace App\DataTables\Adapters;
use Doctrine\ORM\Query\Expr\From;
use Doctrine\ORM\Query; use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator; use Doctrine\ORM\Tools\Pagination\Paginator;
@ -51,12 +52,12 @@ class TwoStepORMAdapter extends ORMAdapter
private bool $use_simple_total = false; private bool $use_simple_total = false;
private \Closure|null $query_modifier; private \Closure|null $query_modifier = null;
public function __construct(ManagerRegistry $registry = null) public function __construct(ManagerRegistry $registry = null)
{ {
parent::__construct($registry); parent::__construct($registry);
$this->detailQueryCallable = static function (QueryBuilder $qb, array $ids) { $this->detailQueryCallable = static function (QueryBuilder $qb, array $ids): never {
throw new \RuntimeException('You need to set the detail_query option to use the TwoStepORMAdapter'); throw new \RuntimeException('You need to set the detail_query option to use the TwoStepORMAdapter');
}; };
} }
@ -66,9 +67,7 @@ class TwoStepORMAdapter extends ORMAdapter
parent::configureOptions($resolver); parent::configureOptions($resolver);
$resolver->setRequired('filter_query'); $resolver->setRequired('filter_query');
$resolver->setDefault('query', function (Options $options) { $resolver->setDefault('query', fn(Options $options) => $options['filter_query']);
return $options['filter_query'];
});
$resolver->setRequired('detail_query'); $resolver->setRequired('detail_query');
$resolver->setAllowedTypes('detail_query', \Closure::class); $resolver->setAllowedTypes('detail_query', \Closure::class);
@ -108,7 +107,7 @@ class TwoStepORMAdapter extends ORMAdapter
} }
} }
/** @var Query\Expr\From $fromClause */ /** @var From $fromClause */
$fromClause = $builder->getDQLPart('from')[0]; $fromClause = $builder->getDQLPart('from')[0];
$identifier = "{$fromClause->getAlias()}.{$this->metadata->getSingleIdentifierFieldName()}"; $identifier = "{$fromClause->getAlias()}.{$this->metadata->getSingleIdentifierFieldName()}";
@ -201,7 +200,7 @@ class TwoStepORMAdapter extends ORMAdapter
/** The paginator count queries can be rather slow, so when query for total count (100ms or longer), /** The paginator count queries can be rather slow, so when query for total count (100ms or longer),
* just return the entity count. * just return the entity count.
*/ */
/** @var Query\Expr\From $from_expr */ /** @var From $from_expr */
$from_expr = $queryBuilder->getDQLPart('from')[0]; $from_expr = $queryBuilder->getDQLPart('from')[0];
return $this->manager->getRepository($from_expr->getFrom())->count([]); return $this->manager->getRepository($from_expr->getFrom())->count([]);

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)',
@ -92,7 +98,7 @@ final class AttachmentDataTable implements DataTableTypeInterface
if ($context->isExternal()) { if ($context->isExternal()) {
return sprintf( return sprintf(
'<a href="%s" class="link-external">%s</a>', '<a href="%s" class="link-external">%s</a>',
htmlspecialchars($context->getURL()), htmlspecialchars((string) $context->getURL()),
htmlspecialchars($value) htmlspecialchars($value)
); );
} }

View file

@ -1,4 +1,7 @@
<?php <?php
declare(strict_types=1);
/* /*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
* *
@ -17,7 +20,6 @@
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
namespace App\DataTables\Column; namespace App\DataTables\Column;
use Omines\DataTablesBundle\Column\AbstractColumn; use Omines\DataTablesBundle\Column\AbstractColumn;

View file

@ -47,7 +47,7 @@ class LocaleDateTimeColumn extends AbstractColumn
} }
if (!$value instanceof DateTimeInterface) { if (!$value instanceof DateTimeInterface) {
$value = new DateTime((string) $value); $value = new \DateTimeImmutable((string) $value);
} }
$formatValues = [ $formatValues = [

View file

@ -22,9 +22,92 @@ declare(strict_types=1);
*/ */
namespace App\DataTables\Filters\Constraints; namespace App\DataTables\Filters\Constraints;
use Doctrine\ORM\QueryBuilder;
use RuntimeException;
/** /**
* An alias of NumberConstraint to use to filter on a DateTime * Similar to NumberConstraint but for DateTime values
*/ */
class DateTimeConstraint extends NumberConstraint class DateTimeConstraint extends AbstractConstraint
{ {
protected const ALLOWED_OPERATOR_VALUES = ['=', '!=', '<', '>', '<=', '>=', 'BETWEEN'];
public function __construct(
string $property,
string $identifier = null,
/**
* The value1 used for comparison (this is the main one used for all mono-value comparisons)
*/
protected \DateTimeInterface|null $value1 = null,
protected ?string $operator = null,
/**
* The second value used when operator is RANGE; this is the upper bound of the range
*/
protected \DateTimeInterface|null $value2 = null)
{
parent::__construct($property, $identifier);
}
public function getValue1(): ?\DateTimeInterface
{
return $this->value1;
}
public function setValue1(\DateTimeInterface|null $value1): void
{
$this->value1 = $value1;
}
public function getValue2(): ?\DateTimeInterface
{
return $this->value2;
}
public function setValue2(?\DateTimeInterface $value2): void
{
$this->value2 = $value2;
}
public function getOperator(): string|null
{
return $this->operator;
}
/**
* @param string $operator
*/
public function setOperator(?string $operator): void
{
$this->operator = $operator;
}
public function isEnabled(): bool
{
return $this->value1 !== null
&& ($this->operator !== null && $this->operator !== '');
}
public function apply(QueryBuilder $queryBuilder): void
{
//If no value is provided then we do not apply a filter
if (!$this->isEnabled()) {
return;
}
//Ensure we have an valid operator
if(!in_array($this->operator, self::ALLOWED_OPERATOR_VALUES, true)) {
throw new \RuntimeException('Invalid operator '. $this->operator . ' provided. Valid operators are '. implode(', ', self::ALLOWED_OPERATOR_VALUES));
}
if ($this->operator !== 'BETWEEN') {
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, $this->operator, $this->value1);
} else {
if ($this->value2 === null) {
throw new RuntimeException("Cannot use operator BETWEEN without value2!");
}
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier . '1', '>=', $this->value1);
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier . '2', '<=', $this->value2);
}
}
} }

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,7 +152,8 @@ 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
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, $this->operator, $this->value, $this->operator === '!=');
return; return;
} }
@ -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

@ -29,12 +29,28 @@ class NumberConstraint extends AbstractConstraint
{ {
protected const ALLOWED_OPERATOR_VALUES = ['=', '!=', '<', '>', '<=', '>=', 'BETWEEN']; protected const ALLOWED_OPERATOR_VALUES = ['=', '!=', '<', '>', '<=', '>=', 'BETWEEN'];
public function getValue1(): float|int|null|\DateTimeInterface public function __construct(
string $property,
string $identifier = null,
/**
* The value1 used for comparison (this is the main one used for all mono-value comparisons)
*/
protected float|int|null $value1 = null,
protected ?string $operator = null,
/**
* The second value used when operator is RANGE; this is the upper bound of the range
*/
protected float|int|null $value2 = null)
{
parent::__construct($property, $identifier);
}
public function getValue1(): float|int|null
{ {
return $this->value1; return $this->value1;
} }
public function setValue1(float|int|\DateTimeInterface|null $value1): void public function setValue1(float|int|null $value1): void
{ {
$this->value1 = $value1; $this->value1 = $value1;
} }
@ -63,22 +79,6 @@ class NumberConstraint extends AbstractConstraint
} }
public function __construct(
string $property,
string $identifier = null,
/**
* The value1 used for comparison (this is the main one used for all mono-value comparisons)
*/
protected float|int|\DateTimeInterface|null $value1 = null,
protected ?string $operator = null,
/**
* The second value used when operator is RANGE; this is the upper bound of the range
*/
protected float|int|\DateTimeInterface|null $value2 = null)
{
parent::__construct($property, $identifier);
}
public function isEnabled(): bool public function isEnabled(): bool
{ {
return $this->value1 !== null return $this->value1 !== null
@ -105,7 +105,13 @@ class NumberConstraint extends AbstractConstraint
} }
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier . '1', '>=', $this->value1); $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier . '1', '>=', $this->value1);
$this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier . '2', '<=', $this->value2);
//Workaround for the amountSum which we need to add twice on postgres. Replace one of the __ with __2 to make it work
//Otherwise we get an error, that __partLot was already defined
$property2 = str_replace('__', '__2', $this->property);
$this->addSimpleAndConstraint($queryBuilder, $property2, $this->identifier . '2', '<=', $this->value2);
} }
} }
} }

View file

@ -30,15 +30,8 @@ class TagsConstraint extends AbstractConstraint
{ {
final public const ALLOWED_OPERATOR_VALUES = ['ANY', 'ALL', 'NONE']; final public const ALLOWED_OPERATOR_VALUES = ['ANY', 'ALL', 'NONE'];
/** public function __construct(string $property, string $identifier = null,
* @param string $value protected ?string $value = null,
*/
public function __construct(string $property, string $identifier = null, /**
* @var string The value to compare to
*/
protected $value = null, /**
* @var string|null The operator to use
*/
protected ?string $operator = '') protected ?string $operator = '')
{ {
parent::__construct($property, $identifier); parent::__construct($property, $identifier);
@ -61,12 +54,12 @@ class TagsConstraint extends AbstractConstraint
return $this; return $this;
} }
public function getValue(): string public function getValue(): ?string
{ {
return $this->value; return $this->value;
} }
public function setValue(string $value): self public function setValue(?string $value): self
{ {
$this->value = $value; $this->value = $value;
return $this; return $this;
@ -92,6 +85,9 @@ 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();

View file

@ -33,9 +33,9 @@ class TextConstraint extends AbstractConstraint
* @param string $value * @param string $value
*/ */
public function __construct(string $property, string $identifier = null, /** public function __construct(string $property, string $identifier = null, /**
* @var string The value to compare to * @var string|null The value to compare to
*/ */
protected $value = null, /** protected ?string $value = null, /**
* @var string|null The operator to use * @var string|null The operator to use
*/ */
protected ?string $operator = '') protected ?string $operator = '')
@ -60,12 +60,12 @@ class TextConstraint extends AbstractConstraint
return $this; return $this;
} }
public function getValue(): string public function getValue(): ?string
{ {
return $this->value; return $this->value;
} }
public function setValue(string $value): self public function setValue(?string $value): self
{ {
$this->value = $value; $this->value = $value;
return $this; return $this;

View file

@ -109,7 +109,7 @@ class ColumnSortHelper
} }
//and the remaining non-visible columns //and the remaining non-visible columns
foreach ($this->columns as $col_id => $col_data) { foreach (array_keys($this->columns) as $col_id) {
if (in_array($col_id, $processed_columns, true)) { if (in_array($col_id, $processed_columns, true)) {
// column already processed // column already processed
continue; continue;

View file

@ -162,7 +162,7 @@ class LogDataTable implements DataTableTypeInterface
if (!$user instanceof User) { if (!$user instanceof User) {
if ($context->isCLIEntry()) { if ($context->isCLIEntry()) {
return sprintf('%s [%s]', return sprintf('%s [%s]',
htmlentities($context->getCLIUsername()), htmlentities((string) $context->getCLIUsername()),
$this->translator->trans('log.cli_user') $this->translator->trans('log.cli_user')
); );
} }

View file

@ -137,7 +137,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')
@ -156,7 +157,7 @@ final class PartsDataTable implements DataTableTypeInterface
'orderField' => 'NATSORT(_partUnit.name)', 'orderField' => 'NATSORT(_partUnit.name)',
'render' => function($value, Part $context): string { 'render' => function($value, Part $context): string {
$partUnit = $context->getPartUnit(); $partUnit = $context->getPartUnit();
if (!$partUnit) { if ($partUnit === null) {
return ''; return '';
} }
@ -184,7 +185,7 @@ final class PartsDataTable implements DataTableTypeInterface
'label' => $this->translator->trans('part.table.manufacturingStatus'), 'label' => $this->translator->trans('part.table.manufacturingStatus'),
'class' => ManufacturingStatus::class, 'class' => ManufacturingStatus::class,
'render' => function (?ManufacturingStatus $status, Part $context): string { 'render' => function (?ManufacturingStatus $status, Part $context): string {
if (!$status) { if ($status === null) {
return ''; return '';
} }

View file

@ -85,7 +85,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
'orderField' => 'NATSORT(part.name)', 'orderField' => 'NATSORT(part.name)',
'render' => function ($value, ProjectBOMEntry $context) { 'render' => function ($value, ProjectBOMEntry $context) {
if(!$context->getPart() instanceof Part) { if(!$context->getPart() instanceof Part) {
return htmlspecialchars($context->getName()); return htmlspecialchars((string) $context->getName());
} }
if($context->getPart() instanceof Part) { if($context->getPart() instanceof Part) {
$tmp = $this->partDataTableHelper->renderName($context->getPart()); $tmp = $this->partDataTableHelper->renderName($context->getPart());
@ -154,7 +154,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
'label' => 'project.bom.instockAmount', 'label' => 'project.bom.instockAmount',
'visible' => false, 'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) { 'render' => function ($value, ProjectBOMEntry $context) {
if ($context->getPart()) { if ($context->getPart() !== null) {
return $this->partDataTableHelper->renderAmount($context->getPart()); return $this->partDataTableHelper->renderAmount($context->getPart());
} }
@ -165,7 +165,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
'label' => 'part.table.storeLocations', 'label' => 'part.table.storeLocations',
'visible' => false, 'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) { 'render' => function ($value, ProjectBOMEntry $context) {
if ($context->getPart()) { if ($context->getPart() !== null) {
return $this->partDataTableHelper->renderStorageLocations($context->getPart()); return $this->partDataTableHelper->renderStorageLocations($context->getPart());
} }

View file

@ -23,6 +23,8 @@ declare(strict_types=1);
namespace App\Doctrine\Functions; namespace App\Doctrine\Functions;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\AST\Functions\FunctionNode; use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\TokenType; use Doctrine\ORM\Query\TokenType;
@ -36,7 +38,7 @@ class Field2 extends FunctionNode
private $values = []; private $values = [];
public function parse(\Doctrine\ORM\Query\Parser $parser): void public function parse(Parser $parser): void
{ {
$parser->match(TokenType::T_IDENTIFIER); $parser->match(TokenType::T_IDENTIFIER);
$parser->match(TokenType::T_OPEN_PARENTHESIS); $parser->match(TokenType::T_OPEN_PARENTHESIS);
@ -58,15 +60,16 @@ class Field2 extends FunctionNode
$parser->match(TokenType::T_CLOSE_PARENTHESIS); $parser->match(TokenType::T_CLOSE_PARENTHESIS);
} }
public function getSql(\Doctrine\ORM\Query\SqlWalker $sqlWalker): string public function getSql(SqlWalker $sqlWalker): string
{ {
$query = 'FIELD2('; $query = 'FIELD2(';
$query .= $this->field->dispatch($sqlWalker); $query .= $this->field->dispatch($sqlWalker);
$query .= ', '; $query .= ', ';
$counter = count($this->values);
for ($i = 0; $i < count($this->values); $i++) { for ($i = 0; $i < $counter; $i++) {
if ($i > 0) { if ($i > 0) {
$query .= ', '; $query .= ', ';
} }
@ -74,8 +77,6 @@ class Field2 extends FunctionNode
$query .= $this->values[$i]->dispatch($sqlWalker); $query .= $this->values[$i]->dispatch($sqlWalker);
} }
$query .= ')'; return $query . ')';
return $query;
} }
} }

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Doctrine\Functions; namespace App\Doctrine\Functions;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Driver\AbstractPostgreSQLDriver; use Doctrine\DBAL\Driver\AbstractPostgreSQLDriver;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform; use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
@ -54,27 +55,59 @@ class Natsort extends FunctionNode
self::$allowSlowNaturalSort = $allow; self::$allowSlowNaturalSort = $allow;
} }
/**
* Check if the slow natural sort is allowed
* @return bool
*/
public static function isSlowNaturalSortAllowed(): bool
{
return self::$allowSlowNaturalSort;
}
/** /**
* Check if the MariaDB version which is connected to supports the natural sort (meaning it has a version of 10.7.0 or higher) * Check if the MariaDB version which is connected to supports the natural sort (meaning it has a version of 10.7.0 or higher)
* The result is cached in memory. * The result is cached in memory.
* @param Connection $connection * @param Connection $connection
* @return bool * @return bool
* @throws \Doctrine\DBAL\Exception * @throws Exception
*/ */
private static function mariaDBSupportsNaturalSort(Connection $connection): bool private function mariaDBSupportsNaturalSort(Connection $connection): bool
{ {
if (self::$supportsNaturalSort !== null) { if (self::$supportsNaturalSort !== null) {
return self::$supportsNaturalSort; return self::$supportsNaturalSort;
} }
$version = $connection->getServerVersion(); $version = $connection->getServerVersion();
//Remove the -MariaDB suffix
$version = str_replace('-MariaDB', '', $version); //Get the effective MariaDB version number
$version = $this->getMariaDbMysqlVersionNumber($version);
//We need at least MariaDB 10.7.0 to support the natural sort //We need at least MariaDB 10.7.0 to support the natural sort
self::$supportsNaturalSort = version_compare($version, '10.7.0', '>='); self::$supportsNaturalSort = version_compare($version, '10.7.0', '>=');
return self::$supportsNaturalSort; return self::$supportsNaturalSort;
} }
/**
* Taken from Doctrine\DBAL\Driver\AbstractMySQLDriver
*
* Detect MariaDB server version, including hack for some mariadb distributions
* that starts with the prefix '5.5.5-'
*
* @param string $versionString Version string as returned by mariadb server, i.e. '5.5.5-Mariadb-10.0.8-xenial'
*/
private function getMariaDbMysqlVersionNumber(string $versionString) : string
{
if ( ! preg_match(
'/^(?:5\.5\.5-)?(mariadb-)?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)/i',
$versionString,
$versionParts
)) {
throw new \RuntimeException('Could not detect MariaDB version from version string ' . $versionString);
}
return $versionParts['major'] . '.' . $versionParts['minor'] . '.' . $versionParts['patch'];
}
public function parse(Parser $parser): void public function parse(Parser $parser): void
{ {
$parser->match(TokenType::T_IDENTIFIER); $parser->match(TokenType::T_IDENTIFIER);
@ -95,7 +128,7 @@ class Natsort extends FunctionNode
return $this->field->dispatch($sqlWalker) . ' COLLATE numeric'; return $this->field->dispatch($sqlWalker) . ' COLLATE numeric';
} }
if ($platform instanceof MariaDBPlatform && self::mariaDBSupportsNaturalSort($sqlWalker->getConnection())) { if ($platform instanceof MariaDBPlatform && $this->mariaDBSupportsNaturalSort($sqlWalker->getConnection())) {
return 'NATURAL_SORT_KEY(' . $this->field->dispatch($sqlWalker) . ')'; return 'NATURAL_SORT_KEY(' . $this->field->dispatch($sqlWalker) . ')';
} }

View file

@ -98,10 +98,9 @@ class SQLiteRegexExtensionMiddlewareDriver extends AbstractDriverMiddleware
* This function returns the index (position) of the first argument in the subsequent arguments. * This function returns the index (position) of the first argument in the subsequent arguments.
* If the first argument is not found or is NULL, 0 is returned. * If the first argument is not found or is NULL, 0 is returned.
* @param string|int|null $value * @param string|int|null $value
* @param mixed ...$array
* @return int * @return int
*/ */
final public static function field(string|int|null $value, ...$array): int final public static function field(string|int|null $value, mixed ...$array): int
{ {
if ($value === null) { if ($value === null) {
return 0; return 0;

View file

@ -0,0 +1,97 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Doctrine\Types;
use DateTime;
use DateTimeInterface;
use DateTimeZone;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\DateTimeImmutableType;
use Doctrine\DBAL\Types\DateTimeType;
use Doctrine\DBAL\Types\Exception\InvalidFormat;
/**
* This DateTimeImmutableType all dates to UTC, so it can be later used with the timezones.
* Taken (and adapted) from here: https://www.doctrine-project.org/projects/doctrine-orm/en/2.6/cookbook/working-with-datetime.html.
*/
class UTCDateTimeImmutableType extends DateTimeImmutableType
{
private static ?DateTimeZone $utc_timezone = null;
/**
* {@inheritdoc}
*
* @param T $value
*
* @return (T is null ? null : string)
*
* @template T
*/
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
if (!self::$utc_timezone instanceof \DateTimeZone) {
self::$utc_timezone = new DateTimeZone('UTC');
}
if ($value instanceof \DateTimeImmutable) {
$value = $value->setTimezone(self::$utc_timezone);
}
return parent::convertToDatabaseValue($value, $platform);
}
/**
* {@inheritDoc}
*
* @param T $value
*
* @template T
*/
public function convertToPHPValue($value, AbstractPlatform $platform): ?\DateTimeImmutable
{
if (!self::$utc_timezone instanceof \DateTimeZone) {
self::$utc_timezone = new DateTimeZone('UTC');
}
if (null === $value || $value instanceof \DateTimeImmutable) {
return $value;
}
$converted = \DateTimeImmutable::createFromFormat(
$platform->getDateTimeFormatString(),
$value,
self::$utc_timezone
);
if (!$converted) {
throw InvalidFormat::new(
$value,
static::class,
$platform->getDateTimeFormatString(),
);
}
return $converted;
}
}

View file

@ -147,7 +147,7 @@ abstract class Attachment extends AbstractNamedDBElement
* @var string|null the original filename the file had, when the user uploaded it * @var string|null the original filename the file had, when the user uploaded it
*/ */
#[ORM\Column(type: Types::STRING, nullable: true)] #[ORM\Column(type: Types::STRING, nullable: true)]
#[Groups(['full', 'attachment:read'])] #[Groups(['attachment:read', 'import'])]
#[Assert\Length(max: 255)] #[Assert\Length(max: 255)]
protected ?string $original_filename = null; protected ?string $original_filename = null;
@ -161,7 +161,7 @@ abstract class Attachment extends AbstractNamedDBElement
* @var string the name of this element * @var string the name of this element
*/ */
#[Assert\NotBlank(message: 'validator.attachment.name_not_blank')] #[Assert\NotBlank(message: 'validator.attachment.name_not_blank')]
#[Groups(['simple', 'extended', 'full', 'attachment:read', 'attachment:write'])] #[Groups(['simple', 'extended', 'full', 'attachment:read', 'attachment:write', 'import'])]
protected string $name = ''; protected string $name = '';
/** /**
@ -173,21 +173,21 @@ abstract class Attachment extends AbstractNamedDBElement
protected ?AttachmentContainingDBElement $element = null; protected ?AttachmentContainingDBElement $element = null;
#[ORM\Column(type: Types::BOOLEAN)] #[ORM\Column(type: Types::BOOLEAN)]
#[Groups(['attachment:read', 'attachment_write'])] #[Groups(['attachment:read', 'attachment_write', 'full', 'import'])]
protected bool $show_in_table = false; protected bool $show_in_table = false;
#[Assert\NotNull(message: 'validator.attachment.must_not_be_null')] #[Assert\NotNull(message: 'validator.attachment.must_not_be_null')]
#[ORM\ManyToOne(targetEntity: AttachmentType::class, inversedBy: 'attachments_with_type')] #[ORM\ManyToOne(targetEntity: AttachmentType::class, inversedBy: 'attachments_with_type')]
#[ORM\JoinColumn(name: 'type_id', nullable: false)] #[ORM\JoinColumn(name: 'type_id', nullable: false)]
#[Selectable] #[Selectable]
#[Groups(['attachment:read', 'attachment:write'])] #[Groups(['attachment:read', 'attachment:write', 'import', 'full'])]
#[ApiProperty(readableLink: false)] #[ApiProperty(readableLink: false)]
protected ?AttachmentType $attachment_type = null; protected ?AttachmentType $attachment_type = null;
#[Groups(['attachment:read'])] #[Groups(['attachment:read'])]
protected ?\DateTime $addedDate = null; protected ?\DateTimeImmutable $addedDate = null;
#[Groups(['attachment:read'])] #[Groups(['attachment:read'])]
protected ?\DateTime $lastModified = null; protected ?\DateTimeImmutable $lastModified = null;
public function __construct() public function __construct()
@ -385,7 +385,7 @@ abstract class Attachment extends AbstractNamedDBElement
return null; return null;
} }
return parse_url($this->getURL(), PHP_URL_HOST); return parse_url((string) $this->getURL(), PHP_URL_HOST);
} }
/** /**
@ -477,7 +477,8 @@ abstract class Attachment extends AbstractNamedDBElement
*/ */
public function setElement(AttachmentContainingDBElement $element): self public function setElement(AttachmentContainingDBElement $element): self
{ {
if (!is_a($element, static::ALLOWED_ELEMENT_CLASS)) { //Do not allow Rector to replace this check with a instanceof. It will not work!!
if (!is_a($element, static::ALLOWED_ELEMENT_CLASS, true)) {
throw new InvalidArgumentException(sprintf('The element associated with a %s must be a %s!', static::class, static::ALLOWED_ELEMENT_CLASS)); throw new InvalidArgumentException(sprintf('The element associated with a %s must be a %s!', static::class, static::ALLOWED_ELEMENT_CLASS));
} }

View file

@ -45,7 +45,7 @@ abstract class AttachmentContainingDBElement extends AbstractNamedDBElement impl
* @phpstan-var Collection<int, AT> * @phpstan-var Collection<int, AT>
* ORM Mapping is done in subclasses (e.g. Part) * ORM Mapping is done in subclasses (e.g. Part)
*/ */
#[Groups(['full'])] #[Groups(['full', 'import'])]
protected Collection $attachments; protected Collection $attachments;
public function __construct() public function __construct()

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Entity\Attachments; namespace App\Entity\Attachments;
use Doctrine\Common\Collections\Criteria;
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter; use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
@ -86,7 +87,7 @@ use Symfony\Component\Validator\Constraints as Assert;
class AttachmentType extends AbstractStructuralDBElement class AttachmentType extends AbstractStructuralDBElement
{ {
#[ORM\OneToMany(mappedBy: 'parent', targetEntity: AttachmentType::class, cascade: ['persist'])] #[ORM\OneToMany(mappedBy: 'parent', targetEntity: AttachmentType::class, cascade: ['persist'])]
#[ORM\OrderBy(['name' => 'ASC'])] #[ORM\OrderBy(['name' => Criteria::ASC])]
protected Collection $children; protected Collection $children;
#[ORM\ManyToOne(targetEntity: AttachmentType::class, inversedBy: 'children')] #[ORM\ManyToOne(targetEntity: AttachmentType::class, inversedBy: 'children')]
@ -102,7 +103,7 @@ class AttachmentType extends AbstractStructuralDBElement
*/ */
#[ORM\Column(type: Types::TEXT)] #[ORM\Column(type: Types::TEXT)]
#[ValidFileFilter] #[ValidFileFilter]
#[Groups(['attachment_type:read', 'attachment_type:write'])] #[Groups(['attachment_type:read', 'attachment_type:write', 'import', 'extended'])]
protected string $filetype_filter = ''; protected string $filetype_filter = '';
/** /**
@ -110,21 +111,21 @@ class AttachmentType extends AbstractStructuralDBElement
*/ */
#[Assert\Valid] #[Assert\Valid]
#[ORM\OneToMany(mappedBy: 'element', targetEntity: AttachmentTypeAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'element', targetEntity: AttachmentTypeAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => 'ASC'])] #[ORM\OrderBy(['name' => Criteria::ASC])]
#[Groups(['attachment_type:read', 'attachment_type:write'])] #[Groups(['attachment_type:read', 'attachment_type:write', 'import', 'full'])]
protected Collection $attachments; protected Collection $attachments;
#[ORM\ManyToOne(targetEntity: AttachmentTypeAttachment::class)] #[ORM\ManyToOne(targetEntity: AttachmentTypeAttachment::class)]
#[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')] #[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
#[Groups(['attachment_type:read', 'attachment_type:write'])] #[Groups(['attachment_type:read', 'attachment_type:write', 'full'])]
protected ?Attachment $master_picture_attachment = null; protected ?Attachment $master_picture_attachment = null;
/** @var Collection<int, AttachmentTypeParameter> /** @var Collection<int, AttachmentTypeParameter>
*/ */
#[Assert\Valid] #[Assert\Valid]
#[ORM\OneToMany(mappedBy: 'element', targetEntity: AttachmentTypeParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'element', targetEntity: AttachmentTypeParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => 'ASC', 'name' => 'ASC'])] #[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
#[Groups(['attachment_type:read', 'attachment_type:write'])] #[Groups(['attachment_type:read', 'attachment_type:write', 'import', 'full'])]
protected Collection $parameters; protected Collection $parameters;
/** /**
@ -134,9 +135,9 @@ class AttachmentType extends AbstractStructuralDBElement
protected Collection $attachments_with_type; protected Collection $attachments_with_type;
#[Groups(['attachment_type:read'])] #[Groups(['attachment_type:read'])]
protected ?\DateTime $addedDate = null; protected ?\DateTimeImmutable $addedDate = null;
#[Groups(['attachment_type:read'])] #[Groups(['attachment_type:read'])]
protected ?\DateTime $lastModified = null; protected ?\DateTimeImmutable $lastModified = null;
public function __construct() public function __construct()

View file

@ -41,14 +41,14 @@ use Symfony\Component\Validator\Constraints as Assert;
abstract class AbstractCompany extends AbstractPartsContainingDBElement abstract class AbstractCompany extends AbstractPartsContainingDBElement
{ {
#[Groups(['company:read'])] #[Groups(['company:read'])]
protected ?\DateTime $addedDate = null; protected ?\DateTimeImmutable $addedDate = null;
#[Groups(['company:read'])] #[Groups(['company:read'])]
protected ?\DateTime $lastModified = null; protected ?\DateTimeImmutable $lastModified = null;
/** /**
* @var string The address of the company * @var string The address of the company
*/ */
#[Groups(['full', 'company:read', 'company:write'])] #[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
#[ORM\Column(type: Types::STRING)] #[ORM\Column(type: Types::STRING)]
#[Assert\Length(max: 255)] #[Assert\Length(max: 255)]
protected string $address = ''; protected string $address = '';
@ -56,7 +56,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
/** /**
* @var string The phone number of the company * @var string The phone number of the company
*/ */
#[Groups(['full', 'company:read', 'company:write'])] #[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
#[ORM\Column(type: Types::STRING)] #[ORM\Column(type: Types::STRING)]
#[Assert\Length(max: 255)] #[Assert\Length(max: 255)]
protected string $phone_number = ''; protected string $phone_number = '';
@ -64,7 +64,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
/** /**
* @var string The fax number of the company * @var string The fax number of the company
*/ */
#[Groups(['full', 'company:read', 'company:write'])] #[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
#[ORM\Column(type: Types::STRING)] #[ORM\Column(type: Types::STRING)]
#[Assert\Length(max: 255)] #[Assert\Length(max: 255)]
protected string $fax_number = ''; protected string $fax_number = '';
@ -73,7 +73,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
* @var string The email address of the company * @var string The email address of the company
*/ */
#[Assert\Email] #[Assert\Email]
#[Groups(['full', 'company:read', 'company:write'])] #[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
#[ORM\Column(type: Types::STRING)] #[ORM\Column(type: Types::STRING)]
#[Assert\Length(max: 255)] #[Assert\Length(max: 255)]
protected string $email_address = ''; protected string $email_address = '';
@ -82,12 +82,12 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
* @var string The website of the company * @var string The website of the company
*/ */
#[Assert\Url] #[Assert\Url]
#[Groups(['full', 'company:read', 'company:write'])] #[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
#[ORM\Column(type: Types::STRING)] #[ORM\Column(type: Types::STRING)]
#[Assert\Length(max: 255)] #[Assert\Length(max: 255)]
protected string $website = ''; protected string $website = '';
#[Groups(['company:read', 'company:write'])] #[Groups(['company:read', 'company:write', 'import', 'full', 'extended'])]
protected string $comment = ''; protected string $comment = '';
/** /**
@ -95,6 +95,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
*/ */
#[ORM\Column(type: Types::STRING)] #[ORM\Column(type: Types::STRING)]
#[Assert\Length(max: 255)] #[Assert\Length(max: 255)]
#[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
protected string $auto_product_url = ''; protected string $auto_product_url = '';
/******************************************************************************** /********************************************************************************

View file

@ -38,7 +38,7 @@ use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\MappedSuperclass(repositoryClass: AbstractPartsContainingRepository::class)] #[ORM\MappedSuperclass(repositoryClass: AbstractPartsContainingRepository::class)]
abstract class AbstractPartsContainingDBElement extends AbstractStructuralDBElement abstract class AbstractPartsContainingDBElement extends AbstractStructuralDBElement
{ {
#[Groups(['full'])] #[Groups(['full', 'import'])]
protected Collection $parameters; protected Collection $parameters;
public function __construct() public function __construct()

View file

@ -34,28 +34,28 @@ use Symfony\Component\Serializer\Annotation\Groups;
trait TimestampTrait trait TimestampTrait
{ {
/** /**
* @var \DateTime|null the date when this element was modified the last time * @var \DateTimeImmutable|null the date when this element was modified the last time
*/ */
#[Groups(['extended', 'full'])] #[Groups(['extended', 'full'])]
#[ApiProperty(writable: false)] #[ApiProperty(writable: false)]
#[ORM\Column(name: 'last_modified', type: Types::DATETIME_MUTABLE, options: ['default' => 'CURRENT_TIMESTAMP'])] #[ORM\Column(name: 'last_modified', type: Types::DATETIME_IMMUTABLE, options: ['default' => 'CURRENT_TIMESTAMP'])]
protected ?\DateTime $lastModified = null; protected ?\DateTimeImmutable $lastModified = null;
/** /**
* @var \DateTime|null the date when this element was created * @var \DateTimeImmutable|null the date when this element was created
*/ */
#[Groups(['extended', 'full'])] #[Groups(['extended', 'full'])]
#[ApiProperty(writable: false)] #[ApiProperty(writable: false)]
#[ORM\Column(name: 'datetime_added', type: Types::DATETIME_MUTABLE, options: ['default' => 'CURRENT_TIMESTAMP'])] #[ORM\Column(name: 'datetime_added', type: Types::DATETIME_IMMUTABLE, options: ['default' => 'CURRENT_TIMESTAMP'])]
protected ?\DateTime $addedDate = null; protected ?\DateTimeImmutable $addedDate = null;
/** /**
* Returns the last time when the element was modified. * Returns the last time when the element was modified.
* Returns null if the element was not yet saved to DB yet. * Returns null if the element was not yet saved to DB yet.
* *
* @return \DateTimeInterface|null the time of the last edit * @return \DateTimeImmutable|null the time of the last edit
*/ */
public function getLastModified(): ?\DateTimeInterface public function getLastModified(): ?\DateTimeImmutable
{ {
return $this->lastModified; return $this->lastModified;
} }
@ -64,9 +64,9 @@ trait TimestampTrait
* Returns the date/time when the element was created. * Returns the date/time when the element was created.
* Returns null if the element was not yet saved to DB yet. * Returns null if the element was not yet saved to DB yet.
* *
* @return \DateTimeInterface|null the creation time of the part * @return \DateTimeImmutable|null the creation time of the part
*/ */
public function getAddedDate(): ?\DateTimeInterface public function getAddedDate(): ?\DateTimeImmutable
{ {
return $this->addedDate; return $this->addedDate;
} }
@ -78,9 +78,9 @@ trait TimestampTrait
#[ORM\PreUpdate] #[ORM\PreUpdate]
public function updateTimestamps(): void public function updateTimestamps(): void
{ {
$this->lastModified = new DateTime('now'); $this->lastModified = new \DateTimeImmutable('now');
if (null === $this->addedDate) { if (null === $this->addedDate) {
$this->addedDate = new DateTime('now'); $this->addedDate = new \DateTimeImmutable('now');
} }
} }
} }

View file

@ -36,33 +36,33 @@ class EDACategoryInfo
* @var string|null The reference prefix of the Part in the schematic. E.g. "R" for resistors, or "C" for capacitors. * @var string|null The reference prefix of the Part in the schematic. E.g. "R" for resistors, or "C" for capacitors.
*/ */
#[Column(type: Types::STRING, nullable: true)] #[Column(type: Types::STRING, nullable: true)]
#[Groups(['full', 'category:read', 'category:write'])] #[Groups(['full', 'category:read', 'category:write', 'import'])]
#[Length(max: 255)] #[Length(max: 255)]
private ?string $reference_prefix = null; private ?string $reference_prefix = null;
/** @var bool|null Visibility of this part to EDA software in trinary logic. True=Visible, False=Invisible, Null=Auto */ /** @var bool|null Visibility of this part to EDA software in trinary logic. True=Visible, False=Invisible, Null=Auto */
#[Column(name: 'invisible', type: Types::BOOLEAN, nullable: true)] //TODO: Rename column to visibility #[Column(name: 'invisible', type: Types::BOOLEAN, nullable: true)] //TODO: Rename column to visibility
#[Groups(['full', 'category:read', 'category:write'])] #[Groups(['full', 'category:read', 'category:write', 'import'])]
private ?bool $visibility = null; private ?bool $visibility = null;
/** @var bool|null If this is set to true, then this part will be excluded from the BOM */ /** @var bool|null If this is set to true, then this part will be excluded from the BOM */
#[Column(type: Types::BOOLEAN, nullable: true)] #[Column(type: Types::BOOLEAN, nullable: true)]
#[Groups(['full', 'category:read', 'category:write'])] #[Groups(['full', 'category:read', 'category:write', 'import'])]
private ?bool $exclude_from_bom = null; private ?bool $exclude_from_bom = null;
/** @var bool|null If this is set to true, then this part will be excluded from the board/the PCB */ /** @var bool|null If this is set to true, then this part will be excluded from the board/the PCB */
#[Column(type: Types::BOOLEAN, nullable: true)] #[Column(type: Types::BOOLEAN, nullable: true)]
#[Groups(['full', 'category:read', 'category:write'])] #[Groups(['full', 'category:read', 'category:write', 'import'])]
private ?bool $exclude_from_board = null; private ?bool $exclude_from_board = null;
/** @var bool|null If this is set to true, then this part will be excluded in the simulation */ /** @var bool|null If this is set to true, then this part will be excluded in the simulation */
#[Column(type: Types::BOOLEAN, nullable: true)] #[Column(type: Types::BOOLEAN, nullable: true)]
#[Groups(['full', 'category:read', 'category:write'])] #[Groups(['full', 'category:read', 'category:write', 'import'])]
private ?bool $exclude_from_sim = true; private ?bool $exclude_from_sim = true;
/** @var string|null The KiCAD schematic symbol, which should be used (the path to the library) */ /** @var string|null The KiCAD schematic symbol, which should be used (the path to the library) */
#[Column(type: Types::STRING, nullable: true)] #[Column(type: Types::STRING, nullable: true)]
#[Groups(['full', 'category:read', 'category:write'])] #[Groups(['full', 'category:read', 'category:write', 'import'])]
#[Length(max: 255)] #[Length(max: 255)]
private ?string $kicad_symbol = null; private ?string $kicad_symbol = null;

View file

@ -34,7 +34,7 @@ class EDAFootprintInfo
{ {
/** @var string|null The KiCAD footprint, which should be used (the path to the library) */ /** @var string|null The KiCAD footprint, which should be used (the path to the library) */
#[Column(type: Types::STRING, nullable: true)] #[Column(type: Types::STRING, nullable: true)]
#[Groups(['full', 'footprint:read', 'footprint:write'])] #[Groups(['full', 'footprint:read', 'footprint:write', 'import'])]
#[Length(max: 255)] #[Length(max: 255)]
private ?string $kicad_footprint = null; private ?string $kicad_footprint = null;

View file

@ -36,45 +36,45 @@ class EDAPartInfo
* @var string|null The reference prefix of the Part in the schematic. E.g. "R" for resistors, or "C" for capacitors. * @var string|null The reference prefix of the Part in the schematic. E.g. "R" for resistors, or "C" for capacitors.
*/ */
#[Column(type: Types::STRING, nullable: true)] #[Column(type: Types::STRING, nullable: true)]
#[Groups(['full', 'eda_info:read', 'eda_info:write'])] #[Groups(['full', 'eda_info:read', 'eda_info:write', 'import'])]
#[Length(max: 255)] #[Length(max: 255)]
private ?string $reference_prefix = null; private ?string $reference_prefix = null;
/** @var string|null The value, which should be shown together with the part (e.g. 470 for a 470 Ohm resistor) */ /** @var string|null The value, which should be shown together with the part (e.g. 470 for a 470 Ohm resistor) */
#[Column(type: Types::STRING, nullable: true)] #[Column(type: Types::STRING, nullable: true)]
#[Groups(['full', 'eda_info:read', 'eda_info:write'])] #[Groups(['full', 'eda_info:read', 'eda_info:write', 'import'])]
#[Length(max: 255)] #[Length(max: 255)]
private ?string $value = null; private ?string $value = null;
/** @var bool|null Visibility of this part to EDA software in trinary logic. True=Visible, False=Invisible, Null=Auto */ /** @var bool|null Visibility of this part to EDA software in trinary logic. True=Visible, False=Invisible, Null=Auto */
#[Column(name: 'invisible', type: Types::BOOLEAN, nullable: true)] //TODO: Rename column to visibility #[Column(name: 'invisible', type: Types::BOOLEAN, nullable: true)] //TODO: Rename column to visibility
#[Groups(['full', 'eda_info:read', 'eda_info:write'])] #[Groups(['full', 'eda_info:read', 'eda_info:write', 'import'])]
private ?bool $visibility = null; private ?bool $visibility = null;
/** @var bool|null If this is set to true, then this part will be excluded from the BOM */ /** @var bool|null If this is set to true, then this part will be excluded from the BOM */
#[Column(type: Types::BOOLEAN, nullable: true)] #[Column(type: Types::BOOLEAN, nullable: true)]
#[Groups(['full', 'eda_info:read', 'eda_info:write'])] #[Groups(['full', 'eda_info:read', 'eda_info:write', 'import'])]
private ?bool $exclude_from_bom = null; private ?bool $exclude_from_bom = null;
/** @var bool|null If this is set to true, then this part will be excluded from the board/the PCB */ /** @var bool|null If this is set to true, then this part will be excluded from the board/the PCB */
#[Column(type: Types::BOOLEAN, nullable: true)] #[Column(type: Types::BOOLEAN, nullable: true)]
#[Groups(['full', 'eda_info:read', 'eda_info:write'])] #[Groups(['full', 'eda_info:read', 'eda_info:write', 'import'])]
private ?bool $exclude_from_board = null; private ?bool $exclude_from_board = null;
/** @var bool|null If this is set to true, then this part will be excluded in the simulation */ /** @var bool|null If this is set to true, then this part will be excluded in the simulation */
#[Column(type: Types::BOOLEAN, nullable: true)] #[Column(type: Types::BOOLEAN, nullable: true)]
#[Groups(['full', 'eda_info:read', 'eda_info:write'])] #[Groups(['full', 'eda_info:read', 'eda_info:write', 'import'])]
private ?bool $exclude_from_sim = null; private ?bool $exclude_from_sim = null;
/** @var string|null The KiCAD schematic symbol, which should be used (the path to the library) */ /** @var string|null The KiCAD schematic symbol, which should be used (the path to the library) */
#[Column(type: Types::STRING, nullable: true)] #[Column(type: Types::STRING, nullable: true)]
#[Groups(['full', 'eda_info:read', 'eda_info:write'])] #[Groups(['full', 'eda_info:read', 'eda_info:write', 'import'])]
#[Length(max: 255)] #[Length(max: 255)]
private ?string $kicad_symbol = null; private ?string $kicad_symbol = null;
/** @var string|null The KiCAD footprint, which should be used (the path to the library) */ /** @var string|null The KiCAD footprint, which should be used (the path to the library) */
#[Column(type: Types::STRING, nullable: true)] #[Column(type: Types::STRING, nullable: true)]
#[Groups(['full', 'eda_info:read', 'eda_info:write'])] #[Groups(['full', 'eda_info:read', 'eda_info:write', 'import'])]
#[Length(max: 255)] #[Length(max: 255)]
private ?string $kicad_footprint = null; private ?string $kicad_footprint = null;

View file

@ -1,4 +1,7 @@
<?php <?php
declare(strict_types=1);
/* /*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
* *
@ -17,7 +20,6 @@
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
namespace App\Entity\LabelSystem; namespace App\Entity\LabelSystem;
enum BarcodeType: string enum BarcodeType: string

View file

@ -43,6 +43,7 @@ namespace App\Entity\LabelSystem;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Embeddable] #[ORM\Embeddable]
@ -53,6 +54,7 @@ class LabelOptions
*/ */
#[Assert\Positive] #[Assert\Positive]
#[ORM\Column(type: Types::FLOAT)] #[ORM\Column(type: Types::FLOAT)]
#[Groups(["extended", "full", "import"])]
protected float $width = 50.0; protected float $width = 50.0;
/** /**
@ -60,38 +62,45 @@ class LabelOptions
*/ */
#[Assert\Positive] #[Assert\Positive]
#[ORM\Column(type: Types::FLOAT)] #[ORM\Column(type: Types::FLOAT)]
#[Groups(["extended", "full", "import"])]
protected float $height = 30.0; protected float $height = 30.0;
/** /**
* @var BarcodeType The type of the barcode that should be used in the label (e.g. 'qr') * @var BarcodeType The type of the barcode that should be used in the label (e.g. 'qr')
*/ */
#[ORM\Column(type: Types::STRING, enumType: BarcodeType::class)] #[ORM\Column(type: Types::STRING, enumType: BarcodeType::class)]
#[Groups(["extended", "full", "import"])]
protected BarcodeType $barcode_type = BarcodeType::NONE; protected BarcodeType $barcode_type = BarcodeType::NONE;
/** /**
* @var LabelPictureType What image should be shown along the label * @var LabelPictureType What image should be shown along the label
*/ */
#[ORM\Column(type: Types::STRING, enumType: LabelPictureType::class)] #[ORM\Column(type: Types::STRING, enumType: LabelPictureType::class)]
#[Groups(["extended", "full", "import"])]
protected LabelPictureType $picture_type = LabelPictureType::NONE; protected LabelPictureType $picture_type = LabelPictureType::NONE;
#[ORM\Column(type: Types::STRING, enumType: LabelSupportedElement::class)] #[ORM\Column(type: Types::STRING, enumType: LabelSupportedElement::class)]
#[Groups(["extended", "full", "import"])]
protected LabelSupportedElement $supported_element = LabelSupportedElement::PART; protected LabelSupportedElement $supported_element = LabelSupportedElement::PART;
/** /**
* @var string any additional CSS for the label * @var string any additional CSS for the label
*/ */
#[ORM\Column(type: Types::TEXT)] #[ORM\Column(type: Types::TEXT)]
#[Groups([ "full", "import"])]
protected string $additional_css = ''; protected string $additional_css = '';
/** @var LabelProcessMode The mode that will be used to interpret the lines /** @var LabelProcessMode The mode that will be used to interpret the lines
*/ */
#[ORM\Column(name: 'lines_mode', type: Types::STRING, enumType: LabelProcessMode::class)] #[ORM\Column(name: 'lines_mode', type: Types::STRING, enumType: LabelProcessMode::class)]
#[Groups(["extended", "full", "import"])]
protected LabelProcessMode $process_mode = LabelProcessMode::PLACEHOLDER; protected LabelProcessMode $process_mode = LabelProcessMode::PLACEHOLDER;
/** /**
* @var string * @var string
*/ */
#[ORM\Column(type: Types::TEXT)] #[ORM\Column(type: Types::TEXT)]
#[Groups(["extended", "full", "import"])]
protected string $lines = ''; protected string $lines = '';
public function getWidth(): float public function getWidth(): float

View file

@ -1,4 +1,7 @@
<?php <?php
declare(strict_types=1);
/* /*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
* *
@ -17,7 +20,6 @@
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
namespace App\Entity\LabelSystem; namespace App\Entity\LabelSystem;
enum LabelPictureType: string enum LabelPictureType: string

View file

@ -1,4 +1,7 @@
<?php <?php
declare(strict_types=1);
/* /*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
* *
@ -17,7 +20,6 @@
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
namespace App\Entity\LabelSystem; namespace App\Entity\LabelSystem;
enum LabelProcessMode: string enum LabelProcessMode: string

View file

@ -41,6 +41,7 @@ declare(strict_types=1);
namespace App\Entity\LabelSystem; namespace App\Entity\LabelSystem;
use Doctrine\Common\Collections\Criteria;
use App\Entity\Attachments\Attachment; use App\Entity\Attachments\Attachment;
use App\Repository\LabelProfileRepository; use App\Repository\LabelProfileRepository;
use App\EntityListeners\TreeCacheInvalidationListener; use App\EntityListeners\TreeCacheInvalidationListener;
@ -51,6 +52,7 @@ use App\Entity\Attachments\LabelAttachment;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/** /**
@ -66,7 +68,7 @@ class LabelProfile extends AttachmentContainingDBElement
* @var Collection<int, LabelAttachment> * @var Collection<int, LabelAttachment>
*/ */
#[ORM\OneToMany(mappedBy: 'element', targetEntity: LabelAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'element', targetEntity: LabelAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => 'ASC'])] #[ORM\OrderBy(['name' => Criteria::ASC])]
protected Collection $attachments; protected Collection $attachments;
#[ORM\ManyToOne(targetEntity: LabelAttachment::class)] #[ORM\ManyToOne(targetEntity: LabelAttachment::class)]
@ -78,6 +80,7 @@ class LabelProfile extends AttachmentContainingDBElement
*/ */
#[Assert\Valid] #[Assert\Valid]
#[ORM\Embedded(class: 'LabelOptions')] #[ORM\Embedded(class: 'LabelOptions')]
#[Groups(["extended", "full", "import"])]
protected LabelOptions $options; protected LabelOptions $options;
/** /**
@ -90,6 +93,7 @@ class LabelProfile extends AttachmentContainingDBElement
* @var bool determines, if this label profile should be shown in the dropdown quick menu * @var bool determines, if this label profile should be shown in the dropdown quick menu
*/ */
#[ORM\Column(type: Types::BOOLEAN)] #[ORM\Column(type: Types::BOOLEAN)]
#[Groups(["extended", "full", "import"])]
protected bool $show_in_dropdown = true; protected bool $show_in_dropdown = true;
public function __construct() public function __construct()

View file

@ -1,4 +1,7 @@
<?php <?php
declare(strict_types=1);
/* /*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
* *
@ -17,7 +20,6 @@
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
namespace App\Entity\LabelSystem; namespace App\Entity\LabelSystem;
use App\Entity\Parts\Part; use App\Entity\Parts\Part;

View file

@ -25,7 +25,7 @@ namespace App\Entity\LogSystem;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractDBElement; use App\Entity\Base\AbstractDBElement;
use App\Entity\UserSystem\User; use App\Entity\UserSystem\User;
use DateTime;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use App\Repository\LogEntryRepository; use App\Repository\LogEntryRepository;
@ -55,10 +55,11 @@ abstract class AbstractLogEntry extends AbstractDBElement
#[ORM\Column(type: Types::STRING)] #[ORM\Column(type: Types::STRING)]
protected string $username = ''; protected string $username = '';
/** @var \DateTime The datetime the event associated with this log entry has occured /**
* @var \DateTimeImmutable The datetime the event associated with this log entry has occured
*/ */
#[ORM\Column(name: 'datetime', type: Types::DATETIME_MUTABLE)] #[ORM\Column(name: 'datetime', type: Types::DATETIME_IMMUTABLE)]
protected \DateTime $timestamp; protected \DateTimeImmutable $timestamp;
/** /**
* @var LogLevel The priority level of the associated level. 0 is highest, 7 lowest * @var LogLevel The priority level of the associated level. 0 is highest, 7 lowest
@ -89,7 +90,7 @@ abstract class AbstractLogEntry extends AbstractDBElement
public function __construct() public function __construct()
{ {
$this->timestamp = new DateTime(); $this->timestamp = new \DateTimeImmutable();
} }
/** /**
@ -164,7 +165,7 @@ abstract class AbstractLogEntry extends AbstractDBElement
/** /**
* Returns the timestamp when the event that caused this log entry happened. * Returns the timestamp when the event that caused this log entry happened.
*/ */
public function getTimestamp(): \DateTimeInterface public function getTimestamp(): \DateTimeImmutable
{ {
return $this->timestamp; return $this->timestamp;
} }
@ -174,7 +175,7 @@ abstract class AbstractLogEntry extends AbstractDBElement
* *
* @return $this * @return $this
*/ */
public function setTimestamp(\DateTime $timestamp): self public function setTimestamp(\DateTimeImmutable $timestamp): self
{ {
$this->timestamp = $timestamp; $this->timestamp = $timestamp;

View file

@ -1,4 +1,7 @@
<?php <?php
declare(strict_types=1);
/* /*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
* *
@ -17,7 +20,6 @@
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
namespace App\Entity\LogSystem; namespace App\Entity\LogSystem;
use Psr\Log\LogLevel as PSRLogLevel; use Psr\Log\LogLevel as PSRLogLevel;

View file

@ -1,4 +1,7 @@
<?php <?php
declare(strict_types=1);
/* /*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
* *
@ -17,7 +20,6 @@
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
namespace App\Entity\LogSystem; namespace App\Entity\LogSystem;
use App\Entity\Attachments\Attachment; use App\Entity\Attachments\Attachment;
@ -120,7 +122,7 @@ enum LogTargetType: int
} }
} }
$elementClass = is_object($element) ? get_class($element) : $element; $elementClass = is_object($element) ? $element::class : $element;
//If no matching type was found, throw an exception //If no matching type was found, throw an exception
throw new \InvalidArgumentException("The given class $elementClass is not a valid log target type."); throw new \InvalidArgumentException("The given class $elementClass is not a valid log target type.");
} }

View file

@ -1,4 +1,7 @@
<?php <?php
declare(strict_types=1);
/* /*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
* *
@ -17,7 +20,6 @@
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
namespace App\Entity\LogSystem; namespace App\Entity\LogSystem;
use App\Entity\Contracts\LogWithEventUndoInterface; use App\Entity\Contracts\LogWithEventUndoInterface;

View file

@ -1,4 +1,7 @@
<?php <?php
declare(strict_types=1);
/* /*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
* *
@ -17,7 +20,6 @@
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
namespace App\Entity\LogSystem; namespace App\Entity\LogSystem;
enum PartStockChangeType: string enum PartStockChangeType: string

View file

@ -52,8 +52,6 @@ class PartStockChangedLogEntry extends AbstractLogEntry
$this->level = LogLevel::INFO; $this->level = LogLevel::INFO;
$this->setTargetElement($lot); $this->setTargetElement($lot);
$this->typeString = 'part_stock_changed';
$this->extra = array_merge($this->extra, [ $this->extra = array_merge($this->extra, [
't' => $type->toExtraShortType(), 't' => $type->toExtraShortType(),
'o' => $old_stock, 'o' => $old_stock,

View file

@ -127,7 +127,7 @@ 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
*/ */

View file

@ -52,7 +52,7 @@ 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
*/ */

View file

@ -49,7 +49,7 @@ 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
*/ */

View file

@ -38,7 +38,7 @@ use League\OAuth2\Client\Token\AccessTokenInterface;
class OAuthToken extends AbstractNamedDBElement implements AccessTokenInterface class OAuthToken extends AbstractNamedDBElement implements AccessTokenInterface
{ {
/** @var string|null The short-term usable OAuth2 token */ /** @var string|null The short-term usable OAuth2 token */
#[ORM\Column(type: 'text', nullable: true)] #[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $token = null; private ?string $token = null;
/** @var \DateTimeImmutable|null The date when the token expires */ /** @var \DateTimeImmutable|null The date when the token expires */
@ -46,7 +46,7 @@ class OAuthToken extends AbstractNamedDBElement implements AccessTokenInterface
private ?\DateTimeImmutable $expires_at = null; private ?\DateTimeImmutable $expires_at = null;
/** @var string|null The refresh token for the OAuth2 auth */ /** @var string|null The refresh token for the OAuth2 auth */
#[ORM\Column(type: 'text', nullable: true)] #[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $refresh_token = null; private ?string $refresh_token = null;
/** /**
@ -92,7 +92,7 @@ class OAuthToken extends AbstractNamedDBElement implements AccessTokenInterface
return $this->token; return $this->token;
} }
public function getExpirationDate(): ?\DateTimeInterface public function getExpirationDate(): ?\DateTimeImmutable
{ {
return $this->expires_at; return $this->expires_at;
} }

View file

@ -116,7 +116,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
* @var string The mathematical symbol for this specification. Can be rendered pretty later. Should be short * @var string The mathematical symbol for this specification. Can be rendered pretty later. Should be short
*/ */
#[Assert\Length(max: 20)] #[Assert\Length(max: 20)]
#[Groups(['full', 'parameter:read', 'parameter:write'])] #[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
#[ORM\Column(type: Types::STRING)] #[ORM\Column(type: Types::STRING)]
protected string $symbol = ''; protected string $symbol = '';
@ -126,7 +126,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
#[Assert\Type(['float', null])] #[Assert\Type(['float', null])]
#[Assert\LessThanOrEqual(propertyPath: 'value_typical', message: 'parameters.validator.min_lesser_typical')] #[Assert\LessThanOrEqual(propertyPath: 'value_typical', message: 'parameters.validator.min_lesser_typical')]
#[Assert\LessThan(propertyPath: 'value_max', message: 'parameters.validator.min_lesser_max')] #[Assert\LessThan(propertyPath: 'value_max', message: 'parameters.validator.min_lesser_max')]
#[Groups(['full', 'parameter:read', 'parameter:write'])] #[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
#[ORM\Column(type: Types::FLOAT, nullable: true)] #[ORM\Column(type: Types::FLOAT, nullable: true)]
protected ?float $value_min = null; protected ?float $value_min = null;
@ -134,7 +134,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
* @var float|null the typical value of this property * @var float|null the typical value of this property
*/ */
#[Assert\Type([null, 'float'])] #[Assert\Type([null, 'float'])]
#[Groups(['full', 'parameter:read', 'parameter:write'])] #[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
#[ORM\Column(type: Types::FLOAT, nullable: true)] #[ORM\Column(type: Types::FLOAT, nullable: true)]
protected ?float $value_typical = null; protected ?float $value_typical = null;
@ -143,14 +143,14 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
*/ */
#[Assert\Type(['float', null])] #[Assert\Type(['float', null])]
#[Assert\GreaterThanOrEqual(propertyPath: 'value_typical', message: 'parameters.validator.max_greater_typical')] #[Assert\GreaterThanOrEqual(propertyPath: 'value_typical', message: 'parameters.validator.max_greater_typical')]
#[Groups(['full', 'parameter:read', 'parameter:write'])] #[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
#[ORM\Column(type: Types::FLOAT, nullable: true)] #[ORM\Column(type: Types::FLOAT, nullable: true)]
protected ?float $value_max = null; protected ?float $value_max = null;
/** /**
* @var string The unit in which the value values are given (e.g. V) * @var string The unit in which the value values are given (e.g. V)
*/ */
#[Groups(['full', 'parameter:read', 'parameter:write'])] #[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
#[ORM\Column(type: Types::STRING)] #[ORM\Column(type: Types::STRING)]
#[Assert\Length(max: 50)] #[Assert\Length(max: 50)]
protected string $unit = ''; protected string $unit = '';
@ -158,7 +158,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
/** /**
* @var string a text value for the given property * @var string a text value for the given property
*/ */
#[Groups(['full', 'parameter:read', 'parameter:write'])] #[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
#[ORM\Column(type: Types::STRING)] #[ORM\Column(type: Types::STRING)]
#[Assert\Length(max: 255)] #[Assert\Length(max: 255)]
protected string $value_text = ''; protected string $value_text = '';
@ -166,7 +166,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
/** /**
* @var string the group this parameter belongs to * @var string the group this parameter belongs to
*/ */
#[Groups(['full', 'parameter:read', 'parameter:write'])] #[Groups(['full', 'parameter:read', 'parameter:write', 'import'])]
#[ORM\Column(name: 'param_group', type: Types::STRING)] #[ORM\Column(name: 'param_group', type: Types::STRING)]
#[Assert\Length(max: 255)] #[Assert\Length(max: 255)]
protected string $group = ''; protected string $group = '';

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Entity\Parts; namespace App\Entity\Parts;
use Doctrine\Common\Collections\Criteria;
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter; use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
@ -91,7 +92,7 @@ use Symfony\Component\Validator\Constraints as Assert;
class Category extends AbstractPartsContainingDBElement class Category extends AbstractPartsContainingDBElement
{ {
#[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)] #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
#[ORM\OrderBy(['name' => 'ASC'])] #[ORM\OrderBy(['name' => Criteria::ASC])]
protected Collection $children; protected Collection $children;
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')] #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
@ -165,7 +166,7 @@ class Category extends AbstractPartsContainingDBElement
#[Assert\Valid] #[Assert\Valid]
#[Groups(['full', 'category:read', 'category:write'])] #[Groups(['full', 'category:read', 'category:write'])]
#[ORM\OneToMany(mappedBy: 'element', targetEntity: CategoryAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'element', targetEntity: CategoryAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => 'ASC'])] #[ORM\OrderBy(['name' => Criteria::ASC])]
protected Collection $attachments; protected Collection $attachments;
#[ORM\ManyToOne(targetEntity: CategoryAttachment::class)] #[ORM\ManyToOne(targetEntity: CategoryAttachment::class)]
@ -178,13 +179,13 @@ class Category extends AbstractPartsContainingDBElement
#[Assert\Valid] #[Assert\Valid]
#[Groups(['full', 'category:read', 'category:write'])] #[Groups(['full', 'category:read', 'category:write'])]
#[ORM\OneToMany(mappedBy: 'element', targetEntity: CategoryParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'element', targetEntity: CategoryParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => 'ASC', 'name' => 'ASC'])] #[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
protected Collection $parameters; protected Collection $parameters;
#[Groups(['category:read'])] #[Groups(['category:read'])]
protected ?\DateTime $addedDate = null; protected ?\DateTimeImmutable $addedDate = null;
#[Groups(['category:read'])] #[Groups(['category:read'])]
protected ?\DateTime $lastModified = null; protected ?\DateTimeImmutable $lastModified = null;
#[Assert\Valid] #[Assert\Valid]
#[ORM\Embedded(class: EDACategoryInfo::class)] #[ORM\Embedded(class: EDACategoryInfo::class)]

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Entity\Parts; namespace App\Entity\Parts;
use Doctrine\Common\Collections\Criteria;
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter; use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
@ -96,7 +97,7 @@ class Footprint extends AbstractPartsContainingDBElement
protected ?AbstractStructuralDBElement $parent = null; protected ?AbstractStructuralDBElement $parent = null;
#[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)] #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
#[ORM\OrderBy(['name' => 'ASC'])] #[ORM\OrderBy(['name' => Criteria::ASC])]
protected Collection $children; protected Collection $children;
#[Groups(['footprint:read', 'footprint:write'])] #[Groups(['footprint:read', 'footprint:write'])]
@ -107,7 +108,7 @@ class Footprint extends AbstractPartsContainingDBElement
*/ */
#[Assert\Valid] #[Assert\Valid]
#[ORM\OneToMany(mappedBy: 'element', targetEntity: FootprintAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'element', targetEntity: FootprintAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => 'ASC'])] #[ORM\OrderBy(['name' => Criteria::ASC])]
#[Groups(['footprint:read', 'footprint:write'])] #[Groups(['footprint:read', 'footprint:write'])]
protected Collection $attachments; protected Collection $attachments;
@ -128,14 +129,14 @@ class Footprint extends AbstractPartsContainingDBElement
*/ */
#[Assert\Valid] #[Assert\Valid]
#[ORM\OneToMany(mappedBy: 'element', targetEntity: FootprintParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'element', targetEntity: FootprintParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => 'ASC', 'name' => 'ASC'])] #[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
#[Groups(['footprint:read', 'footprint:write'])] #[Groups(['footprint:read', 'footprint:write'])]
protected Collection $parameters; protected Collection $parameters;
#[Groups(['footprint:read'])] #[Groups(['footprint:read'])]
protected ?\DateTime $addedDate = null; protected ?\DateTimeImmutable $addedDate = null;
#[Groups(['footprint:read'])] #[Groups(['footprint:read'])]
protected ?\DateTime $lastModified = null; protected ?\DateTimeImmutable $lastModified = null;
#[Assert\Valid] #[Assert\Valid]
#[ORM\Embedded(class: EDAFootprintInfo::class)] #[ORM\Embedded(class: EDAFootprintInfo::class)]

View file

@ -31,31 +31,32 @@ use Symfony\Component\Serializer\Annotation\Groups;
/** /**
* This class represents a reference to a info provider inside a part. * This class represents a reference to a info provider inside a part.
* @see \App\Tests\Entity\Parts\InfoProviderReferenceTest
*/ */
#[Embeddable] #[Embeddable]
class InfoProviderReference class InfoProviderReference
{ {
/** @var string|null The key referencing the provider used to get this part, or null if it was not provided by a data provider */ /** @var string|null The key referencing the provider used to get this part, or null if it was not provided by a data provider */
#[Column(type: 'string', nullable: true)] #[Column(type: Types::STRING, nullable: true)]
#[Groups(['provider_reference:read'])] #[Groups(['provider_reference:read', 'full'])]
private ?string $provider_key = null; private ?string $provider_key = null;
/** @var string|null The id of this part inside the provider system or null if the part was not provided by a data provider */ /** @var string|null The id of this part inside the provider system or null if the part was not provided by a data provider */
#[Column(type: 'string', nullable: true)] #[Column(type: Types::STRING, nullable: true)]
#[Groups(['provider_reference:read'])] #[Groups(['provider_reference:read', 'full'])]
private ?string $provider_id = null; private ?string $provider_id = null;
/** /**
* @var string|null The url of this part inside the provider system or null if this info is not existing * @var string|null The url of this part inside the provider system or null if this info is not existing
*/ */
#[Column(type: 'string', nullable: true)] #[Column(type: Types::STRING, nullable: true)]
#[Groups(['provider_reference:read'])] #[Groups(['provider_reference:read', 'full'])]
private ?string $provider_url = null; private ?string $provider_url = null;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true, options: ['default' => null])] #[Column(type: Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])]
#[Groups(['provider_reference:read'])] #[Groups(['provider_reference:read', 'full'])]
private ?\DateTime $last_updated = null; private ?\DateTimeImmutable $last_updated = null;
/** /**
* Constructing is forbidden from outside. * Constructing is forbidden from outside.
@ -94,9 +95,8 @@ class InfoProviderReference
/** /**
* Gets the time, when the part was last time updated by the provider. * Gets the time, when the part was last time updated by the provider.
* @return \DateTimeInterface|null
*/ */
public function getLastUpdated(): ?\DateTimeInterface public function getLastUpdated(): ?\DateTimeImmutable
{ {
return $this->last_updated; return $this->last_updated;
} }
@ -139,7 +139,7 @@ class InfoProviderReference
$ref->provider_key = $provider_key; $ref->provider_key = $provider_key;
$ref->provider_id = $provider_id; $ref->provider_id = $provider_id;
$ref->provider_url = $provider_url; $ref->provider_url = $provider_url;
$ref->last_updated = new \DateTime(); $ref->last_updated = new \DateTimeImmutable();
return $ref; return $ref;
} }
@ -154,7 +154,7 @@ class InfoProviderReference
$ref->provider_key = $dto->provider_key; $ref->provider_key = $dto->provider_key;
$ref->provider_id = $dto->provider_id; $ref->provider_id = $dto->provider_id;
$ref->provider_url = $dto->provider_url; $ref->provider_url = $dto->provider_url;
$ref->last_updated = new \DateTime(); $ref->last_updated = new \DateTimeImmutable();
return $ref; return $ref;
} }
} }

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Entity\Parts; namespace App\Entity\Parts;
use Doctrine\Common\Collections\Criteria;
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter; use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
@ -95,7 +96,7 @@ class Manufacturer extends AbstractCompany
protected ?AbstractStructuralDBElement $parent = null; protected ?AbstractStructuralDBElement $parent = null;
#[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)] #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
#[ORM\OrderBy(['name' => 'ASC'])] #[ORM\OrderBy(['name' => Criteria::ASC])]
protected Collection $children; protected Collection $children;
/** /**
@ -103,7 +104,7 @@ class Manufacturer extends AbstractCompany
*/ */
#[Assert\Valid] #[Assert\Valid]
#[ORM\OneToMany(mappedBy: 'element', targetEntity: ManufacturerAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'element', targetEntity: ManufacturerAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => 'ASC'])] #[ORM\OrderBy(['name' => Criteria::ASC])]
#[Groups(['manufacturer:read', 'manufacturer:write'])] #[Groups(['manufacturer:read', 'manufacturer:write'])]
#[ApiProperty(readableLink: false, writableLink: true)] #[ApiProperty(readableLink: false, writableLink: true)]
protected Collection $attachments; protected Collection $attachments;
@ -118,7 +119,7 @@ class Manufacturer extends AbstractCompany
*/ */
#[Assert\Valid] #[Assert\Valid]
#[ORM\OneToMany(mappedBy: 'element', targetEntity: ManufacturerParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'element', targetEntity: ManufacturerParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => 'ASC', 'name' => 'ASC'])] #[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
#[Groups(['manufacturer:read', 'manufacturer:write'])] #[Groups(['manufacturer:read', 'manufacturer:write'])]
#[ApiProperty(readableLink: false, writableLink: true)] #[ApiProperty(readableLink: false, writableLink: true)]
protected Collection $parameters; protected Collection $parameters;

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Entity\Parts; namespace App\Entity\Parts;
use Doctrine\Common\Collections\Criteria;
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter; use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
@ -98,7 +99,7 @@ class MeasurementUnit extends AbstractPartsContainingDBElement
* or m (for meters). * or m (for meters).
*/ */
#[Assert\Length(max: 10)] #[Assert\Length(max: 10)]
#[Groups(['extended', 'full', 'import', 'measurement_unit:read', 'measurement_unit:write'])] #[Groups(['simple', 'extended', 'full', 'import', 'measurement_unit:read', 'measurement_unit:write'])]
#[ORM\Column(name: 'unit', type: Types::STRING, nullable: true)] #[ORM\Column(name: 'unit', type: Types::STRING, nullable: true)]
protected ?string $unit = null; protected ?string $unit = null;
@ -109,7 +110,7 @@ class MeasurementUnit extends AbstractPartsContainingDBElement
* @var bool Determines if the amount value associated with this unit should be treated as integer. * @var bool Determines if the amount value associated with this unit should be treated as integer.
* Set to false, to measure continuous sizes likes masses or lengths. * Set to false, to measure continuous sizes likes masses or lengths.
*/ */
#[Groups(['extended', 'full', 'import', 'measurement_unit:read', 'measurement_unit:write'])] #[Groups(['simple', 'extended', 'full', 'import', 'measurement_unit:read', 'measurement_unit:write'])]
#[ORM\Column(name: 'is_integer', type: Types::BOOLEAN)] #[ORM\Column(name: 'is_integer', type: Types::BOOLEAN)]
protected bool $is_integer = false; protected bool $is_integer = false;
@ -118,12 +119,12 @@ class MeasurementUnit extends AbstractPartsContainingDBElement
* Useful for sizes like meters. For this the unit must be set * Useful for sizes like meters. For this the unit must be set
*/ */
#[Assert\Expression('this.isUseSIPrefix() == false or this.getUnit() != null', message: 'validator.measurement_unit.use_si_prefix_needs_unit')] #[Assert\Expression('this.isUseSIPrefix() == false or this.getUnit() != null', message: 'validator.measurement_unit.use_si_prefix_needs_unit')]
#[Groups(['full', 'import', 'measurement_unit:read', 'measurement_unit:write'])] #[Groups(['simple', 'full', 'import', 'measurement_unit:read', 'measurement_unit:write'])]
#[ORM\Column(name: 'use_si_prefix', type: Types::BOOLEAN)] #[ORM\Column(name: 'use_si_prefix', type: Types::BOOLEAN)]
protected bool $use_si_prefix = false; protected bool $use_si_prefix = false;
#[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class, cascade: ['persist'])] #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class, cascade: ['persist'])]
#[ORM\OrderBy(['name' => 'ASC'])] #[ORM\OrderBy(['name' => Criteria::ASC])]
protected Collection $children; protected Collection $children;
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')] #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
@ -137,7 +138,7 @@ class MeasurementUnit extends AbstractPartsContainingDBElement
*/ */
#[Assert\Valid] #[Assert\Valid]
#[ORM\OneToMany(mappedBy: 'element', targetEntity: MeasurementUnitAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'element', targetEntity: MeasurementUnitAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => 'ASC'])] #[ORM\OrderBy(['name' => Criteria::ASC])]
#[Groups(['measurement_unit:read', 'measurement_unit:write'])] #[Groups(['measurement_unit:read', 'measurement_unit:write'])]
protected Collection $attachments; protected Collection $attachments;
@ -150,14 +151,14 @@ class MeasurementUnit extends AbstractPartsContainingDBElement
*/ */
#[Assert\Valid] #[Assert\Valid]
#[ORM\OneToMany(mappedBy: 'element', targetEntity: MeasurementUnitParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'element', targetEntity: MeasurementUnitParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => 'ASC', 'name' => 'ASC'])] #[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
#[Groups(['measurement_unit:read', 'measurement_unit:write'])] #[Groups(['measurement_unit:read', 'measurement_unit:write'])]
protected Collection $parameters; protected Collection $parameters;
#[Groups(['measurement_unit:read'])] #[Groups(['measurement_unit:read'])]
protected ?\DateTime $addedDate = null; protected ?\DateTimeImmutable $addedDate = null;
#[Groups(['measurement_unit:read'])] #[Groups(['measurement_unit:read'])]
protected ?\DateTime $lastModified = null; protected ?\DateTimeImmutable $lastModified = null;
/** /**

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