Compare commits

...

35 Commits
gtin ... v2.7.0

Author SHA1 Message Date
Jan Böhmer
7d6b84af3d Bumped version to 2.7.0 2026-02-16 13:32:13 +01:00
Copilot
80492a7b68 Use native ARM runners for ARM Docker image builds (#1248)
* Initial plan

* Use ARM runners for ARM Docker image builds

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Fix artifact naming and add comments for latest=false flavor

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Remove trailing commas from tag configuration in merge job

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Remove duplicate tag entries and clean up configuration

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>
2026-02-16 13:15:52 +01:00
Copilot
7069af4054 Updated dockerfiles to not rely on node deb packages, that are not supported for armhf anymore
* Initial plan

* Refactor Dockerfiles to use Node.js multistage builds

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Fix node-builder stage with stub translations file

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Improve stub translations file creation using heredoc

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Build real translations in node-builder stage via cache warmup

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Improve error handling for cache warmup fallback

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Use native build platform for node-builder stage

Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>

* Do not include fallback for case that translations not exist

* Use newer format for dockerfile-frankenphp

* Dockfile added caching to package managers

* Fixed frankenphp build

* Fixed complain about missing symfony runtime

* Use caching for frankenphp dockerfile

* add target arch to dockerfile caches, to avoid problems

* add targetarch arg

* moved targetarch argument to correct position

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jbtronics <5410681+jbtronics@users.noreply.github.com>
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-02-16 12:50:52 +01:00
Jan Böhmer
05a9e4d035 Merge remote-tracking branch 'origin/master' 2026-02-15 22:33:23 +01:00
Jan Böhmer
be808e28bc Updated dependencies 2026-02-15 22:29:16 +01:00
Jan Böhmer
7354b37ef6 New Crowdin updates (#1228)
* New translations messages.en.xlf (German)

* New translations messages.en.xlf (German)

* New translations validators.en.xlf (Polish)

* New translations security.en.xlf (Danish)

* New translations security.en.xlf (Ukrainian)

* New translations security.en.xlf (German)

* New translations security.en.xlf (Hungarian)

* New translations security.en.xlf (Dutch)

* New translations security.en.xlf (Chinese Simplified)

* New translations messages.en.xlf (English)

* New translations validators.en.xlf (English)

* New translations security.en.xlf (English)

* New translations frontend.en.xlf (Danish)

* New translations frontend.en.xlf (German)

* New translations frontend.en.xlf (Hungarian)

* New translations frontend.en.xlf (Ukrainian)

* New translations frontend.en.xlf (Chinese Simplified)

* New translations frontend.en.xlf (English)

* New translations messages.en.xlf (German)
2026-02-15 22:24:00 +01:00
Jan Böhmer
6afca44897 Use xxh3 hashes instead of encoding for info provider cache keys 2026-02-15 22:19:44 +01:00
Jan Böhmer
c17cf2baa1 Fixed rendering of tristate checkboxes 2026-02-15 21:49:18 +01:00
Jan Böhmer
c00556829a Focus the first newly created number input for collection_types
Improves PR #1240
2026-02-15 21:43:47 +01:00
Jan Böhmer
f024c4b09e Merge branch 'autofocus-fields' 2026-02-15 21:37:12 +01:00
Jan Böhmer
8e0fcdb73b Added some part datatables optimization 2026-02-15 20:07:38 +01:00
Jan Böhmer
e19929249f Mark parts datatables query as read only for some memory optimizations 2026-02-15 19:30:53 +01:00
Jan Böhmer
f6764170e1 Fixed phpstan issues 2026-02-15 16:16:15 +01:00
Niklas
1641708508 Added API endpoint for generating labels (#1234)
* init API endpoint for generating labels

* Improved API docs for label endpoint

* Improved LabelGenerationProcessor

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-02-15 16:03:07 +01:00
d-buchmann
97a74815d3 Fix fallback filename (#1238)
Fixes #1231.
Modify tests to account for this case.
2026-02-15 14:41:25 +01:00
Jan Böhmer
7998cdcd71 Added hint about HTML block to twig label documentation 2026-02-15 14:24:31 +01:00
Jan Böhmer
5e9f7a11a3 Catch more errors of twig labels 2026-02-15 14:11:31 +01:00
Jan Böhmer
1c6bf3f472 Allow more useful functions in twig labels 2026-02-15 14:07:50 +01:00
Jan Böhmer
aed2652f1d Added functions to retrieve associated parts of an element within twig labels
This fixes #1239
2026-02-15 13:52:56 +01:00
Jan Böhmer
233c5e8550 Fixed phpunit and phpstan issues 2026-02-15 00:49:12 +01:00
Jan Böhmer
6b83c772cc Moved user twig functions requiring repo access to its own extension service 2026-02-15 00:28:40 +01:00
Jan Böhmer
1996db6a53 Moved remaining twig extensions to new attributes system 2026-02-15 00:23:30 +01:00
Jan Böhmer
f69b0889eb Ran rector to convert some our twig extensions to use #[AsTwigXX] attributes 2026-02-14 23:53:31 +01:00
Jan Böhmer
c8b1320bb9 Updated rector config 2026-02-14 23:50:42 +01:00
Jan Böhmer
e11cb7d5cb Fixed phpunit tests 2026-02-14 23:46:39 +01:00
Jan Böhmer
097041a43a Ran rector 2026-02-14 23:33:40 +01:00
Jan Böhmer
b21d294cf8 Ran rector and made tests final 2026-02-14 23:32:43 +01:00
Jan Böhmer
43d72faf48 Updated label fonts 2026-02-14 22:46:46 +01:00
Jan Böhmer
bc9a93d71f Removed sodium compat, as all supported PHP versions support it natively nowadays 2026-02-14 22:31:53 +01:00
Jan Böhmer
df0ac76394 Updated composer dependencies that required major version changes 2026-02-14 22:24:36 +01:00
Jan Böhmer
66040b687f Updated dependencies 2026-02-14 22:17:05 +01:00
Jan Böhmer
7a83581597 Merge branch 'gtin' 2026-02-14 22:12:39 +01:00
buchmann
47c0d78985 only autofocus if new 2026-02-11 14:26:36 +01:00
buchmann
76f0b05a09 Autofocus for frequently used input fields
Fixes #1157.
- Focus `name` field on new part
- Focus `amount` on add/withdraw modal
- Focus first "number type" input on any newly added collectionType table row... (debatable)

It would be even more favorable if the user could configure if they want to use autofocus and/or for which fields/dialogs it should be enabled.
2026-02-11 14:10:05 +01:00
Marc
41252d8bb9 Implement URLHandlerInfoProviderInterface in BuerklinProvider (#1235)
* Implement URLHandlerInfoProviderInterface in BuerklinProvider

Added URL handling capabilities to BuerklinProvider.

* Refactor ID extraction logic in BuerklinProvider

* Add tests for BuerklinProvider URLHandlerInfoProviderInterface

* Revert "Refactor ID extraction logic in BuerklinProvider"

This reverts commit 5f65176636.

* Exclude 'p' from valid ID return in BuerklinProvider
2026-02-10 15:26:26 +01:00
239 changed files with 2813 additions and 1857 deletions

View File

@@ -12,7 +12,7 @@ opcache.max_accelerated_files = 20000
opcache.memory_consumption = 256
opcache.enable_file_override = 1
memory_limit = 256M
memory_limit = 512M
upload_max_filesize=256M
post_max_size=300M
post_max_size=300M

View File

@@ -1,4 +1,3 @@
worker {
file ./public/index.php
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
}

View File

@@ -15,8 +15,20 @@ on:
- 'v*.*.*-**'
jobs:
docker:
runs-on: ubuntu-latest
build:
strategy:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
platform-slug: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
platform-slug: arm64
- platform: linux/arm/v7
runner: ubuntu-24.04-arm
platform-slug: armv7
runs-on: ${{ matrix.runner }}
steps:
-
name: Checkout
@@ -32,13 +44,12 @@ jobs:
# Mark the image build from master as latest (as we dont have really releases yet)
tags: |
type=edge,branch=master
type=ref,event=branch,
type=ref,event=tag,
type=ref,event=branch
type=ref,event=tag
type=schedule
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=branch
type=ref,event=pr
labels: |
org.opencontainers.image.source=${{ github.event.repository.clone_url }}
@@ -49,12 +60,10 @@ jobs:
org.opencontainers.image.source=https://github.com/Part-DB/Part-DB-symfony
org.opencontainers.image.authors=Jan Böhmer
org.opencontainers.licenses=AGPL-3.0-or-later
# Disable automatic 'latest' tag in build jobs - it will be created in merge job
flavor: |
latest=false
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: 'arm64,arm'
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -67,13 +76,85 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=image,name=jbtronics/part-db1,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
-
name: Export digest
if: github.event_name != 'pull_request'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
-
name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.platform-slug }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
if: github.event_name != 'pull_request'
steps:
-
name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
with:
images: |
jbtronics/part-db1
tags: |
type=edge,branch=master
type=ref,event=branch
type=ref,event=tag
type=schedule
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=pr
-
name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'jbtronics/part-db1@sha256:%s ' *)
-
name: Inspect image
run: |
docker buildx imagetools inspect jbtronics/part-db1:${{ steps.docker_meta.outputs.version }}

View File

@@ -15,8 +15,20 @@ on:
- 'v*.*.*-**'
jobs:
docker:
runs-on: ubuntu-latest
build:
strategy:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
platform-slug: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
platform-slug: arm64
- platform: linux/arm/v7
runner: ubuntu-24.04-arm
platform-slug: armv7
runs-on: ${{ matrix.runner }}
steps:
-
name: Checkout
@@ -32,13 +44,12 @@ jobs:
# Mark the image build from master as latest (as we dont have really releases yet)
tags: |
type=edge,branch=master
type=ref,event=branch,
type=ref,event=tag,
type=ref,event=branch
type=ref,event=tag
type=schedule
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=branch
type=ref,event=pr
labels: |
org.opencontainers.image.source=${{ github.event.repository.clone_url }}
@@ -49,12 +60,10 @@ jobs:
org.opencontainers.image.source=https://github.com/Part-DB/Part-DB-server
org.opencontainers.image.authors=Jan Böhmer
org.opencontainers.licenses=AGPL-3.0-or-later
# Disable automatic 'latest' tag in build jobs - it will be created in merge job
flavor: |
latest=false
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: 'arm64,arm'
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -67,14 +76,86 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile-frankenphp
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
outputs: type=image,name=partdborg/part-db,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
-
name: Export digest
if: github.event_name != 'pull_request'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
-
name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.platform-slug }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
if: github.event_name != 'pull_request'
steps:
-
name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
with:
images: |
partdborg/part-db
tags: |
type=edge,branch=master
type=ref,event=branch
type=ref,event=tag
type=schedule
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=pr
-
name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'partdborg/part-db@sha256:%s ' *)
-
name: Inspect image
run: |
docker buildx imagetools inspect partdborg/part-db:${{ steps.docker_meta.outputs.version }}

View File

@@ -1,15 +1,75 @@
# syntax=docker/dockerfile:1
ARG BASE_IMAGE=debian:bookworm-slim
ARG PHP_VERSION=8.4
ARG NODE_VERSION=22
# Node.js build stage for building frontend assets
# Use native platform for build stage as it's platform-independent
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-bookworm-slim AS node-builder
ARG TARGETARCH
WORKDIR /app
# Install composer and minimal PHP for running Symfony commands
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Use BuildKit cache mounts for apt in builder stage
RUN --mount=type=cache,id=apt-cache-node-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-node-$TARGETARCH,target=/var/lib/apt/lists \
apt-get update && apt-get install -y --no-install-recommends \
php-cli \
php-xml \
php-mbstring \
unzip \
git \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Copy composer files and install dependencies (needed for Symfony UX assets)
COPY composer.json composer.lock symfony.lock ./
# Use BuildKit cache for Composer downloads
RUN --mount=type=cache,id=composer-cache,target=/root/.cache/composer \
composer install --no-scripts --no-autoloader --no-dev --prefer-dist --ignore-platform-reqs
# Copy all application files needed for cache warmup and webpack build
COPY .env* ./
COPY bin ./bin
COPY config ./config
COPY src ./src
COPY translations ./translations
COPY public ./public
COPY assets ./assets
COPY webpack.config.js ./
# Generate autoloader
RUN composer dump-autoload
# Create required directories for cache warmup
RUN mkdir -p var/cache var/log uploads public/media
# Dump translations, which we need for cache warmup
RUN php bin/console cache:warmup -n --env=prod 2>&1
# Copy package files and install node dependencies
COPY package.json yarn.lock ./
# Use BuildKit cache for yarn/npm
RUN --mount=type=cache,id=yarn-cache,target=/root/.cache/yarn \
--mount=type=cache,id=npm-cache,target=/root/.npm \
yarn install --network-timeout 600000
# Build the assets
RUN yarn build
# Clean up
RUN yarn cache clean && rm -rf node_modules/
# Base stage for PHP
FROM ${BASE_IMAGE} AS base
ARG PHP_VERSION
ARG TARGETARCH
# Install needed dependencies for PHP build
#RUN apt-get update && apt-get install -y pkg-config curl libcurl4-openssl-dev libicu-dev \
# libpng-dev libjpeg-dev libfreetype6-dev gnupg zip libzip-dev libjpeg62-turbo-dev libonig-dev libxslt-dev libwebp-dev vim \
# && apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get -y install \
# Use BuildKit cache mounts for apt in base stage
RUN --mount=type=cache,id=apt-cache-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-$TARGETARCH,target=/var/lib/apt/lists \
apt-get update && apt-get -y install \
apt-transport-https \
lsb-release \
ca-certificates \
@@ -39,19 +99,10 @@ RUN apt-get update && apt-get -y install \
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
&& 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 -sL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get update && apt-get install -y \
nodejs \
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/* && \
npm install -g yarn
# Install composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
@@ -65,14 +116,12 @@ ENV APACHE_ENVVARS=$APACHE_CONFDIR/envvars
# : ${APACHE_RUN_USER:=www-data}
# export 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"; \
set -eux; . "$APACHE_ENVVARS"; \
\
# logs should go to stdout / stderr
ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log"; \
ln -sfT /dev/stdout "$APACHE_LOG_DIR/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";
RUN sed -ri 's/^export ([^=]+)=(.*)$/: ${\1:=\2}\nexport \1/' "$APACHE_ENVVARS" && \
set -eux; . "$APACHE_ENVVARS" && \
ln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log" && \
ln -sfT /dev/stdout "$APACHE_LOG_DIR/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"
# ---
@@ -141,7 +190,6 @@ COPY --chown=www-data:www-data . .
# Setup apache2
RUN a2dissite 000-default.conf && \
a2ensite symfony.conf && \
# Enable php-fpm
a2enmod proxy_fcgi setenvif && \
a2enconf php${PHP_VERSION}-fpm && \
a2enconf docker-php && \
@@ -149,12 +197,13 @@ RUN a2dissite 000-default.conf && \
# Install composer and yarn dependencies for Part-DB
USER www-data
RUN composer install -a --no-dev && \
# Use BuildKit cache for Composer when running as www-data by setting COMPOSER_CACHE_DIR
RUN --mount=type=cache,id=composer-cache,target=/tmp/.composer-cache \
COMPOSER_CACHE_DIR=/tmp/.composer-cache composer install -a --no-dev && \
composer clear-cache
RUN yarn install --network-timeout 600000 && \
yarn build && \
yarn cache clean && \
rm -rf node_modules/
# Copy built frontend assets from node-builder stage
COPY --from=node-builder --chown=www-data:www-data /app/public/build ./public/build
# Use docker env to output logs to stdout
ENV APP_ENV=docker
@@ -166,10 +215,12 @@ USER root
RUN sed -i "s/PHP_VERSION/${PHP_VERSION}/g" ./.docker/partdb-entrypoint.sh
# 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
# Convert CRLF -> LF and install entrypoint scripts with executable mode
RUN sed -i 's/\r$//' ./.docker/partdb-entrypoint.sh ./.docker/apache2-foreground && \
install -m 0755 ./.docker/partdb-entrypoint.sh /usr/local/bin/ && \
install -m 0755 ./.docker/apache2-foreground /usr/local/bin/
ENTRYPOINT ["partdb-entrypoint.sh"]
CMD ["apache2-foreground"]
CMD ["/usr/local/bin/apache2-foreground"]
# https://httpd.apache.org/docs/2.4/stopping.html#gracefulstop
STOPSIGNAL SIGWINCH

View File

@@ -1,6 +1,72 @@
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
ARG NODE_VERSION=22
RUN apt-get update && apt-get -y install \
# Node.js build stage for building frontend assets
# Use native platform for build stage as it's platform-independent
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-bookworm-slim AS node-builder
ARG TARGETARCH
WORKDIR /app
# Install composer and minimal PHP for running Symfony commands
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Use BuildKit cache mounts for apt in builder stage
RUN --mount=type=cache,id=apt-cache-node-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-node-$TARGETARCH,target=/var/lib/apt/lists \
apt-get update && apt-get install -y --no-install-recommends \
php-cli \
php-xml \
php-mbstring \
unzip \
git \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Copy composer files and install dependencies (needed for Symfony UX assets)
COPY composer.json composer.lock symfony.lock ./
# Use BuildKit cache for Composer downloads
RUN --mount=type=cache,id=composer-cache,target=/root/.cache/composer \
composer install --no-scripts --no-autoloader --no-dev --prefer-dist --ignore-platform-reqs
# Copy all application files needed for cache warmup and webpack build
COPY .env* ./
COPY bin ./bin
COPY config ./config
COPY src ./src
COPY translations ./translations
COPY public ./public
COPY assets ./assets
COPY webpack.config.js ./
# Generate autoloader
RUN composer dump-autoload
# Create required directories for cache warmup
RUN mkdir -p var/cache var/log uploads public/media
# Dump translations, which we need for cache warmup
RUN php bin/console cache:warmup -n --env=prod 2>&1
# Copy package files and install node dependencies
COPY package.json yarn.lock ./
# Use BuildKit cache for yarn/npm
RUN --mount=type=cache,id=yarn-cache,target=/root/.cache/yarn \
--mount=type=cache,id=npm-cache,target=/root/.npm \
yarn install --network-timeout 600000
# Build the assets
RUN yarn build
# Clean up
RUN yarn cache clean && rm -rf node_modules/
# FrankenPHP base stage
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
ARG TARGETARCH
RUN --mount=type=cache,id=apt-cache-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-$TARGETARCH,target=/var/lib/apt/lists \
apt-get update && apt-get -y install \
curl \
ca-certificates \
mariadb-client \
@@ -13,24 +79,6 @@ RUN apt-get update && apt-get -y install \
zip \
&& apt-get -y autoremove && apt-get clean autoclean && rm -rf /var/lib/apt/lists/*;
RUN set -eux; \
# Run NodeSource setup script
curl -sL https://deb.nodesource.com/setup_22.x | bash -; \
\
# Install Node.js
apt-get update; \
apt-get install -y --no-install-recommends \
nodejs; \
\
# Cleanup
apt-get -y autoremove; \
apt-get clean autoclean; \
rm -rf /var/lib/apt/lists/*; \
\
# Install Yarn via npm
npm install -g yarn
# Install PHP
RUN set -eux; \
install-php-extensions \
@@ -76,14 +124,11 @@ COPY --link . ./
RUN set -eux; \
mkdir -p var/cache var/log; \
composer dump-autoload --classmap-authoritative --no-dev; \
composer dump-env prod; \
composer run-script --no-dev post-install-cmd; \
chmod +x bin/console; sync;
RUN yarn install --network-timeout 600000 && \
yarn build && \
yarn cache clean && \
rm -rf node_modules/
# Copy built frontend assets from node-builder stage
COPY --from=node-builder /app/public/build ./public/build
# Use docker env to output logs to stdout
ENV APP_ENV=docker
@@ -102,8 +147,8 @@ VOLUME ["/var/www/html/uploads", "/var/www/html/public/media"]
HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1
# See https://caddyserver.com/docs/conventions#file-locations for details
ENV XDG_CONFIG_HOME /config
ENV XDG_DATA_HOME /data
ENV XDG_CONFIG_HOME=/config
ENV XDG_DATA_HOME=/data
EXPOSE 80
EXPOSE 443

View File

@@ -1 +1 @@
2.6.0
2.7.0

View File

@@ -81,7 +81,7 @@ export default class extends Controller {
//Afterwards return the newly created row
if(targetTable.tBodies[0]) {
targetTable.tBodies[0].insertAdjacentHTML('beforeend', newElementStr);
ret = targetTable.tBodies[0].lastElementChild;
ret = targetTable.tBodies[0].lastElementChild;
} else { //Otherwise just insert it
targetTable.insertAdjacentHTML('beforeend', newElementStr);
ret = targetTable.lastElementChild;
@@ -90,10 +90,20 @@ export default class extends Controller {
//Trigger an event to notify other components that a new element has been created, so they can for example initialize select2 on it
targetTable.dispatchEvent(new CustomEvent("collection:elementAdded", {bubbles: true}));
this.focusNumberInput(ret);
return ret;
}
focusNumberInput(element) {
const fields = element.querySelectorAll("input[type=number]");
//Focus the first available number input field to open the numeric keyboard on mobile devices
if(fields.length > 0) {
fields[0].focus();
}
}
/**
* This action opens a file dialog to select multiple files and then creates a new element for each file, where
* the file is assigned to the input field.

View File

@@ -5,6 +5,7 @@ export default class extends Controller
{
connect() {
this.element.addEventListener('show.bs.modal', event => this._handleModalOpen(event));
this.element.addEventListener('shown.bs.modal', event => this._handleModalShown(event));
}
_handleModalOpen(event) {
@@ -61,4 +62,8 @@ export default class extends Controller
amountInput.setAttribute('max', lotAmount);
}
}
_handleModalShown(event) {
this.element.querySelector('input[name="amount"]').focus();
}
}

View File

@@ -17,7 +17,7 @@
"api-platform/json-api": "^4.0.0",
"api-platform/symfony": "^4.0.0",
"beberlei/doctrineextensions": "^1.2",
"brick/math": "^0.13.1",
"brick/math": "^0.14.8",
"brick/schema": "^0.2.0",
"composer/ca-bundle": "^1.5",
"composer/package-versions-deprecated": "^1.11.99.5",
@@ -28,7 +28,7 @@
"doctrine/orm": "^3.2.0",
"dompdf/dompdf": "^3.1.2",
"gregwar/captcha-bundle": "^2.1.0",
"hshn/base64-encoded-file": "^5.0",
"hshn/base64-encoded-file": "^6.0",
"jbtronics/2fa-webauthn": "^3.0.0",
"jbtronics/dompdf-font-loader-bundle": "^1.0.0",
"jbtronics/settings-bundle": "^3.0.0",
@@ -45,7 +45,6 @@
"nelmio/security-bundle": "^3.0",
"nyholm/psr7": "^1.1",
"omines/datatables-bundle": "^0.10.0",
"paragonie/sodium_compat": "^1.21",
"part-db/label-fonts": "^1.0",
"part-db/swap-bundle": "^6.0.0",
"phpoffice/phpspreadsheet": "^5.0.0",
@@ -70,7 +69,7 @@
"symfony/http-client": "7.4.*",
"symfony/http-kernel": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/monolog-bundle": "^3.1",
"symfony/monolog-bundle": "^4.0",
"symfony/process": "7.4.*",
"symfony/property-access": "7.4.*",
"symfony/property-info": "7.4.*",
@@ -88,7 +87,7 @@
"symfony/web-link": "7.4.*",
"symfony/webpack-encore-bundle": "^v2.0.1",
"symfony/yaml": "7.4.*",
"symplify/easy-coding-standard": "^12.5.20",
"symplify/easy-coding-standard": "^13.0",
"tecnickcom/tc-lib-barcode": "^2.1.4",
"tiendanube/gtinvalidation": "^1.0",
"twig/cssinliner-extra": "^3.0",
@@ -129,7 +128,7 @@
},
"suggest": {
"ext-bcmath": "Used to improve price calculation performance",
"ext-gmp": "Used to improve price calculation performanice"
"ext-gmp": "Used to improve price calculation performance"
},
"config": {
"preferred-install": {

585
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,12 +20,14 @@
declare(strict_types=1);
use Symfony\Config\DoctrineConfig;
/**
* This class extends the default doctrine ORM configuration to enable native lazy objects on PHP 8.4+.
* We have to do this in a PHP file, because the yaml file does not support conditionals on PHP version.
*/
return static function(\Symfony\Config\DoctrineConfig $doctrine) {
return static function(DoctrineConfig $doctrine) {
//On PHP 8.4+ we can use native lazy objects, which are much more efficient than proxies.
if (PHP_VERSION_ID >= 80400) {
$doctrine->orm()->enableNativeLazyObjects(true);

View File

@@ -1387,7 +1387,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* bubble?: bool|Param, // Default: true
* interactive_only?: bool|Param, // Default: false
* app_name?: scalar|Param|null, // Default: null
* fill_extra_context?: bool|Param, // Default: false
* include_stacktraces?: bool|Param, // Default: false
* process_psr_3_messages?: array{
* enabled?: bool|Param|null, // Default: null
@@ -1407,7 +1406,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* activation_strategy?: scalar|Param|null, // Default: null
* stop_buffering?: bool|Param, // Default: true
* passthru_level?: scalar|Param|null, // Default: null
* excluded_404s?: list<scalar|Param|null>,
* excluded_http_codes?: list<array{ // Default: []
* code?: scalar|Param|null,
* urls?: list<scalar|Param|null>,
@@ -1421,9 +1419,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* url?: scalar|Param|null,
* exchange?: scalar|Param|null,
* exchange_name?: scalar|Param|null, // Default: "log"
* room?: scalar|Param|null,
* message_format?: scalar|Param|null, // Default: "text"
* api_version?: scalar|Param|null, // Default: null
* channel?: scalar|Param|null, // Default: null
* bot_name?: scalar|Param|null, // Default: "Monolog"
* use_attachment?: scalar|Param|null, // Default: true
@@ -1432,9 +1427,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* icon_emoji?: scalar|Param|null, // Default: null
* webhook_url?: scalar|Param|null,
* exclude_fields?: list<scalar|Param|null>,
* team?: scalar|Param|null,
* notify?: scalar|Param|null, // Default: false
* nickname?: scalar|Param|null, // Default: "Monolog"
* token?: scalar|Param|null,
* region?: scalar|Param|null,
* source?: scalar|Param|null,
@@ -1452,12 +1444,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* store?: scalar|Param|null, // Default: null
* connection_timeout?: scalar|Param|null,
* persistent?: bool|Param,
* dsn?: scalar|Param|null,
* hub_id?: scalar|Param|null, // Default: null
* client_id?: scalar|Param|null, // Default: null
* auto_log_stacks?: scalar|Param|null, // Default: false
* release?: scalar|Param|null, // Default: null
* environment?: scalar|Param|null, // Default: null
* message_type?: scalar|Param|null, // Default: 0
* parse_mode?: scalar|Param|null, // Default: null
* disable_webpage_preview?: bool|Param|null, // Default: null
@@ -1467,7 +1453,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* topic?: int|Param, // Default: null
* factor?: int|Param, // Default: 1
* tags?: list<scalar|Param|null>,
* console_formater_options?: mixed, // Deprecated: "monolog.handlers..console_formater_options.console_formater_options" is deprecated, use "monolog.handlers..console_formater_options.console_formatter_options" instead.
* console_formatter_options?: mixed, // Default: []
* formatter?: scalar|Param|null,
* nested?: bool|Param, // Default: false
@@ -1478,15 +1463,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* chunk_size?: scalar|Param|null, // Default: 1420
* encoder?: "json"|"compressed_json"|Param,
* },
* mongo?: string|array{
* id?: scalar|Param|null,
* host?: scalar|Param|null,
* port?: scalar|Param|null, // Default: 27017
* user?: scalar|Param|null,
* pass?: scalar|Param|null,
* database?: scalar|Param|null, // Default: "monolog"
* collection?: scalar|Param|null, // Default: "logs"
* },
* mongodb?: string|array{
* id?: scalar|Param|null, // ID of a MongoDB\Client service
* uri?: scalar|Param|null,
@@ -1529,7 +1505,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* id: scalar|Param|null,
* method?: scalar|Param|null, // Default: null
* },
* lazy?: bool|Param, // Default: true
* verbosity_levels?: array{
* VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR"
* VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING"

View File

@@ -91,18 +91,20 @@ in [official documentation](https://twig.symfony.com/doc/3.x/).
Twig allows you for much more complex and dynamic label generation. You can use loops, conditions, and functions to create
the label content and you can access almost all data Part-DB offers. The label templates are evaluated in a special sandboxed environment,
where only certain operations are allowed. Only read access to entities is allowed. However as it circumvents Part-DB normal permission system,
where only certain operations are allowed. Only read access to entities is allowed. However, as it circumvents Part-DB normal permission system,
the twig mode is only available to users with the "Twig mode" permission.
It is useful to use the HTML embed feature of the editor, to have a block where you can write the twig code without worrying about the WYSIWYG editor messing with your code.
The following variables are in injected into Twig and can be accessed using `{% raw %}{{ variable }}{% endraw %}` (
or `{% raw %}{{ variable.property }}{% endraw %}`):
| Variable name | Description |
|--------------------------------------------|--------------------------------------------------------------------------------------|
| `{% raw %}{{ element }}{% endraw %}` | The target element, selected in label dialog. |
| `{% raw %}{{ element }}{% endraw %}` | The target element, selected in label dialog. |
| `{% raw %}{{ user }}{% endraw %}` | The current logged in user. Null if you are not logged in |
| `{% raw %}{{ install_title }}{% endraw %}` | The name of the current Part-DB instance (similar to [[INSTALL_NAME]] placeholder). |
| `{% raw %}{{ page }}{% endraw %}` | The page number (the nth-element for which the label is generated |
| `{% raw %}{{ page }}{% endraw %}` | The page number (the nth-element for which the label is generated ) |
| `{% raw %}{{ last_page }}{% endraw %}` | The page number of the last element. Equals the number of all pages / element labels |
| `{% raw %}{{ paper_width }}{% endraw %}` | The width of the label paper in mm |
| `{% raw %}{{ paper_height }}{% endraw %}` | The height of the label paper in mm |
@@ -236,12 +238,18 @@ certain data:
#### Functions
| Function name | Description |
|----------------------------------------------|-----------------------------------------------------------------------------------------------|
| `placeholder(placeholder, element)` | Get the value of a placeholder of an element |
| `entity_type(element)` | Get the type of an entity as string |
| `entity_url(element, type)` | Get the URL to a specific entity type page (e.g. `info`, `edit`, etc.) |
| `barcode_svg(content, type)` | Generate a barcode SVG from the content and type (e.g. `QRCODE`, `CODE128` etc.). A svg string is returned, which you need to data uri encode to inline it. |
| Function name | Description |
|------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `placeholder(placeholder, element)` | Get the value of a placeholder of an element |
| `entity_type(element)` | Get the type of an entity as string |
| `entity_url(element, type)` | Get the URL to a specific entity type page (e.g. `info`, `edit`, etc.) |
| `barcode_svg(content, type)` | Generate a barcode SVG from the content and type (e.g. `QRCODE`, `CODE128` etc.). A svg string is returned, which you need to data uri encode to inline it. |
| `associated_parts(element)` | Get the associated parts of an element like a storagelocation, footprint, etc. Only the directly associated parts are returned |
| `associated_parts_r(element)` | Get the associated parts of an element like a storagelocation, footprint, etc. including all sub-entities recursively (e.g. sub-locations) |
| `associated_parts_count(element)` | Get the count of associated parts of an element like a storagelocation, footprint, excluding sub-entities |
| `associated_parts_count_r(element)` | Get the count of associated parts of an element like a storagelocation, footprint, including all sub-entities recursively (e.g. sub-locations) |
| `type_label(element)` | Get the name of the type of an element (e.g. "Part", "Storage location", etc.) |
| `type_label_p(element)` | Get the name of the type of an element in plural form (e.g. "Parts", "Storage locations", etc.) |
### Filters
@@ -285,5 +293,5 @@ If you want to use a different (more beautiful) font, you can use the [custom fo
feature.
There is the [Noto](https://www.google.com/get/noto/) font family from Google, which supports a lot of languages and is
available in different styles (regular, bold, italic, bold-italic).
For example, you can use [Noto CJK](https://github.com/notofonts/noto-cjk) for more beautiful Chinese, Japanese,
and Korean characters.
For example, you can use [Noto CJK](https://github.com/notofonts/noto-cjk) for more beautiful Chinese, Japanese,
and Korean characters.

View File

@@ -18,7 +18,7 @@ use Rector\Symfony\Set\SymfonySetList;
use Rector\TypeDeclaration\Rector\StmtsAwareInterface\DeclareStrictTypesRector;
return RectorConfig::configure()
->withComposerBased(phpunit: true)
->withComposerBased(phpunit: true, symfony: true)
->withSymfonyContainerPhp(__DIR__ . '/tests/symfony-container.php')
->withSymfonyContainerXml(__DIR__ . '/var/cache/dev/App_KernelDevDebugContainer.xml')
@@ -36,8 +36,6 @@ return RectorConfig::configure()
PHPUnitSetList::PHPUNIT_90,
PHPUnitSetList::PHPUNIT_110,
PHPUnitSetList::PHPUNIT_CODE_QUALITY,
])
->withRules([
@@ -59,6 +57,9 @@ return RectorConfig::configure()
PreferPHPUnitThisCallRector::class,
//Do not replace 'GET' with class constant,
LiteralGetToRequestClassConstantRector::class,
//Do not move help text of commands to the command class, as we want to keep the help text in the command definition for better readability
\Rector\Symfony\Symfony73\Rector\Class_\CommandHelpToAttributeRector::class
])
//Do not apply rules to Symfony own files
@@ -67,6 +68,7 @@ return RectorConfig::configure()
__DIR__ . '/src/Kernel.php',
__DIR__ . '/config/preload.php',
__DIR__ . '/config/bundles.php',
__DIR__ . '/config/reference.php'
])
;

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\OpenApi\Model\RequestBody;
use ApiPlatform\OpenApi\Model\Response;
use App\Entity\LabelSystem\LabelSupportedElement;
use App\State\LabelGenerationProcessor;
use App\Validator\Constraints\Misc\ValidRange;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource for generating PDF labels for parts, part lots, or storage locations.
* This endpoint allows generating labels using saved label profiles.
*/
#[ApiResource(
uriTemplate: '/labels/generate',
description: 'Generate PDF labels for parts, part lots, or storage locations using label profiles.',
operations: [
new Post(
inputFormats: ['json' => ['application/json']],
outputFormats: [],
openapi: new Operation(
responses: [
"200" => new Response(description: "PDF file containing the generated labels"),
],
summary: 'Generate PDF labels',
description: 'Generate PDF labels for one or more elements using a label profile. Returns a PDF file.',
requestBody: new RequestBody(
description: 'Label generation request',
required: true,
),
),
)
],
processor: LabelGenerationProcessor::class,
)]
class LabelGenerationRequest
{
/**
* @var int The ID of the label profile to use for generation
*/
#[Assert\NotBlank(message: 'Profile ID is required')]
#[Assert\Positive(message: 'Profile ID must be a positive integer')]
public int $profileId = 0;
/**
* @var string Comma-separated list of element IDs or ranges (e.g., "1,2,5-10,15")
*/
#[Assert\NotBlank(message: 'Element IDs are required')]
#[ValidRange()]
#[ApiProperty(example: "1,2,5-10,15")]
public string $elementIds = '';
/**
* @var LabelSupportedElement|null Optional: Override the element type. If not provided, uses profile's default.
*/
public ?LabelSupportedElement $elementType = null;
}

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
use App\DataTables\LogDataTable;
use App\Entity\Attachments\AttachmentUpload;
use App\Entity\Parts\Category;
@@ -151,7 +152,7 @@ final class PartController extends AbstractController
$jobId = $request->query->get('jobId');
$bulkJob = null;
if ($jobId) {
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
$bulkJob = $this->em->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
// Verify user owns this job
if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
$bulkJob = null;
@@ -172,7 +173,7 @@ final class PartController extends AbstractController
throw $this->createAccessDeniedException('Invalid CSRF token');
}
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
$bulkJob = $this->em->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
if (!$bulkJob || $bulkJob->getCreatedBy() !== $this->getUser()) {
throw $this->createNotFoundException('Bulk import job not found');
}
@@ -338,7 +339,7 @@ final class PartController extends AbstractController
$jobId = $request->query->get('jobId');
$bulkJob = null;
if ($jobId) {
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
$bulkJob = $this->em->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
// Verify user owns this job
if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
$bulkJob = null;

View File

@@ -147,10 +147,7 @@ class SecurityController extends AbstractController
'label' => 'user.settings.pw_confirm.label',
],
'invalid_message' => 'password_must_match',
'constraints' => [new Length([
'min' => 6,
'max' => 128,
])],
'constraints' => [new Length(min: 6, max: 128)],
]);
$builder->add('submit', SubmitType::class, [

View File

@@ -295,10 +295,7 @@ class UserSettingsController extends AbstractController
'autocomplete' => 'new-password',
],
],
'constraints' => [new Length([
'min' => 6,
'max' => 128,
])],
'constraints' => [new Length(min: 6, max: 128)],
])
->add('submit', SubmitType::class, [
'label' => 'save',

View File

@@ -160,7 +160,7 @@ class PartSearchFilter implements FilterInterface
if ($search_dbId) {
$expressions[] = $queryBuilder->expr()->eq('part.id', ':id_exact');
$queryBuilder->setParameter('id_exact', (int) $this->keyword,
\Doctrine\DBAL\ParameterType::INTEGER);
ParameterType::INTEGER);
}
//Guard condition

View File

@@ -47,6 +47,7 @@ use App\Services\EntityURLGenerator;
use App\Services\Formatters\AmountFormatter;
use App\Settings\BehaviorSettings\TableSettings;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
use Omines\DataTablesBundle\Column\TextColumn;
@@ -333,6 +334,7 @@ final class PartsDataTable implements DataTableTypeInterface
->addSelect('orderdetails')
->addSelect('attachments')
->addSelect('storelocations')
->addSelect('projectBomEntries')
->from(Part::class, 'part')
->leftJoin('part.category', 'category')
->leftJoin('part.master_picture_attachment', 'master_picture_attachment')
@@ -347,6 +349,7 @@ final class PartsDataTable implements DataTableTypeInterface
->leftJoin('part.partUnit', 'partUnit')
->leftJoin('part.partCustomState', 'partCustomState')
->leftJoin('part.parameters', 'parameters')
->leftJoin('part.project_bom_entries', 'projectBomEntries')
->where('part.id IN (:ids)')
->setParameter('ids', $ids)
@@ -364,7 +367,12 @@ final class PartsDataTable implements DataTableTypeInterface
->addGroupBy('attachments')
->addGroupBy('partUnit')
->addGroupBy('partCustomState')
->addGroupBy('parameters');
->addGroupBy('parameters')
->addGroupBy('projectBomEntries')
->setHint(Query::HINT_READ_ONLY, true)
->setHint(Query::HINT_FORCE_PARTIAL_LOAD, false)
;
//Get the results in the same order as the IDs were passed
FieldHelper::addOrderByFieldParam($builder, 'part.id', 'ids');

View File

@@ -41,6 +41,12 @@ declare(strict_types=1);
namespace App\Entity\LabelSystem;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\OpenApi\Model\Operation;
use Doctrine\Common\Collections\Criteria;
use App\Entity\Attachments\Attachment;
use App\Repository\LabelProfileRepository;
@@ -58,6 +64,22 @@ use Symfony\Component\Validator\Constraints as Assert;
/**
* @extends AttachmentContainingDBElement<LabelAttachment>
*/
#[ApiResource(
operations: [
new Get(
normalizationContext: ['groups' => ['label_profile:read', 'simple']],
security: "is_granted('read', object)",
openapi: new Operation(summary: 'Get a label profile by ID')
),
new GetCollection(
normalizationContext: ['groups' => ['label_profile:read', 'simple']],
security: "is_granted('@labels.create_labels')",
openapi: new Operation(summary: 'List all available label profiles')
),
],
paginationEnabled: false,
)]
#[ApiFilter(SearchFilter::class, properties: ['options.supported_element' => 'exact', 'show_in_dropdown' => 'exact'])]
#[UniqueEntity(['name', 'options.supported_element'])]
#[ORM\Entity(repositoryClass: LabelProfileRepository::class)]
#[ORM\EntityListeners([TreeCacheInvalidationListener::class])]
@@ -80,20 +102,21 @@ class LabelProfile extends AttachmentContainingDBElement
*/
#[Assert\Valid]
#[ORM\Embedded(class: 'LabelOptions')]
#[Groups(["extended", "full", "import"])]
#[Groups(["extended", "full", "import", "label_profile:read"])]
protected LabelOptions $options;
/**
* @var string The comment info for this element
*/
#[ORM\Column(type: Types::TEXT)]
#[Groups(["extended", "full", "import", "label_profile:read"])]
protected string $comment = '';
/**
* @var bool determines, if this label profile should be shown in the dropdown quick menu
*/
#[ORM\Column(type: Types::BOOLEAN)]
#[Groups(["extended", "full", "import"])]
#[Groups(["extended", "full", "import", "label_profile:read"])]
protected bool $show_in_dropdown = true;
public function __construct()

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber\UserSystem;
use App\Entity\Parts\Part;

View File

@@ -42,15 +42,14 @@ declare(strict_types=1);
namespace App\Exceptions;
use RuntimeException;
use Twig\Error\Error;
class TwigModeException extends RuntimeException
{
private const PROJECT_PATH = __DIR__ . '/../../';
public function __construct(?Error $previous = null)
public function __construct(?\Throwable $previous = null)
{
parent::__construct($previous->getMessage(), 0, $previous);
parent::__construct($previous?->getMessage() ?? "Unknown message", 0, $previous);
}
/**

View File

@@ -71,6 +71,7 @@ class BaseEntityAdminForm extends AbstractType
'label' => 'name.label',
'attr' => [
'placeholder' => 'part.name.placeholder',
'autofocus' => $is_new,
],
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
]);

View File

@@ -122,9 +122,7 @@ class AttachmentFormType extends AbstractType
],
'constraints' => [
//new AllowedFileExtension(),
new File([
'maxSize' => $options['max_file_size'],
]),
new File(maxSize: $options['max_file_size']),
],
]);

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Form\InfoProviderSystem;
use Symfony\Component\Validator\Constraints\Range;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
@@ -61,7 +62,7 @@ class FieldToProviderMappingType extends AbstractType
'style' => 'width: 80px;'
],
'constraints' => [
new \Symfony\Component\Validator\Constraints\Range(['min' => 1, 'max' => 10]),
new Range(min: 1, max: 10),
],
]);
}

View File

@@ -117,6 +117,7 @@ class PartBaseType extends AbstractType
'label' => 'part.edit.name',
'attr' => [
'placeholder' => 'part.edit.name.placeholder',
'autofocus' => $new_part,
],
])
->add('description', RichTextEditorType::class, [

View File

@@ -177,10 +177,7 @@ class UserAdminForm extends AbstractType
'required' => false,
'mapped' => false,
'disabled' => !$this->security->isGranted('set_password', $entity) || $entity->isSamlUser(),
'constraints' => [new Length([
'min' => 6,
'max' => 128,
])],
'constraints' => [new Length(min: 6, max: 128)],
])
->add('need_pw_change', CheckboxType::class, [

View File

@@ -92,9 +92,7 @@ class UserSettingsType extends AbstractType
'accept' => 'image/*',
],
'constraints' => [
new File([
'maxSize' => '5M',
]),
new File(maxSize: '5M'),
],
])
->add('aboutMe', RichTextEditorType::class, [

View File

@@ -22,6 +22,8 @@ declare(strict_types=1);
*/
namespace App\Services\ImportExportSystem;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
@@ -275,7 +277,7 @@ class BOMImporter
$mapped_entries = []; // Collect all mapped entries for validation
// Fetch suppliers once for efficiency
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
$supplierSPNKeys = [];
$suppliersByName = []; // Map supplier names to supplier objects
foreach ($suppliers as $supplier) {
@@ -371,7 +373,7 @@ class BOMImporter
if ($supplier_spn !== null) {
// Query for orderdetails with matching supplier and SPN
$orderdetail = $this->entityManager->getRepository(\App\Entity\PriceInformations\Orderdetail::class)
$orderdetail = $this->entityManager->getRepository(Orderdetail::class)
->findOneBy([
'supplier' => $supplier,
'supplierpartnr' => $supplier_spn,
@@ -535,7 +537,7 @@ class BOMImporter
];
// Add dynamic supplier fields based on available suppliers in the database
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
foreach ($suppliers as $supplier) {
$supplierName = $supplier->getName();
$targets[$supplierName . ' SPN'] = [
@@ -570,7 +572,7 @@ class BOMImporter
];
// Add supplier-specific patterns
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
foreach ($suppliers as $supplier) {
$supplierName = $supplier->getName();
$supplierLower = strtolower($supplierName);

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\ImportExportSystem;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Helpers\FilenameSanatizer;
@@ -177,7 +178,7 @@ class EntityExporter
$colIndex = 1;
foreach ($columns as $column) {
$cellCoordinate = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex) . $rowIndex;
$cellCoordinate = Coordinate::stringFromColumnIndex($colIndex) . $rowIndex;
$worksheet->setCellValue($cellCoordinate, $column);
$colIndex++;
}
@@ -265,11 +266,14 @@ class EntityExporter
//Sanitize the filename
$filename = FilenameSanatizer::sanitizeFilename($filename);
//Remove percent for fallback
$fallback = str_replace("%", "_", $filename);
// Create the disposition of the file
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$filename,
u($filename)->ascii()->toString(),
$fallback,
);
// Set the content disposition
$response->headers->set('Content-Disposition', $disposition);

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\ImportExportSystem;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parts\Category;
@@ -419,14 +420,14 @@ class EntityImporter
'worksheet_title' => $worksheet->getTitle()
]);
$highestColumnIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn);
$highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);
for ($row = 1; $row <= $highestRow; $row++) {
$rowData = [];
// Read all columns using numeric index
for ($colIndex = 1; $colIndex <= $highestColumnIndex; $colIndex++) {
$col = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($colIndex);
$col = Coordinate::stringFromColumnIndex($colIndex);
try {
$cellValue = $worksheet->getCell("{$col}{$row}")->getCalculatedValue();
$rowData[] = $cellValue ?? '';

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\InfoProviderSystem\DTOs;
use Doctrine\ORM\Exception\ORMException;
use App\Entity\Parts\Part;
use Doctrine\ORM\EntityManagerInterface;
use Traversable;
@@ -176,7 +177,7 @@ readonly class BulkSearchResponseDTO implements \ArrayAccess, \IteratorAggregate
* @param array $data
* @param EntityManagerInterface $entityManager
* @return BulkSearchResponseDTO
* @throws \Doctrine\ORM\Exception\ORMException
* @throws ORMException
*/
public static function fromSerializableRepresentation(array $data, EntityManagerInterface $entityManager): BulkSearchResponseDTO
{

View File

@@ -82,7 +82,7 @@ final class PartInfoRetriever
protected function searchInProvider(InfoProviderInterface $provider, string $keyword): array
{
//Generate key and escape reserved characters from the provider id
$escaped_keyword = urlencode($keyword);
$escaped_keyword = hash('xxh3', $keyword);
return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) {
//Set the expiration time
$item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 1);
@@ -108,7 +108,7 @@ final class PartInfoRetriever
}
//Generate key and escape reserved characters from the provider id
$escaped_part_id = urlencode($part_id);
$escaped_part_id = hash('xxh3', $part_id);
return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) {
//Set the expiration time
$item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 1);
@@ -145,4 +145,4 @@ final class PartInfoRetriever
return $this->dto_to_entity_converter->convertPart($details);
}
}
}

View File

@@ -34,7 +34,7 @@ use App\Settings\InfoProviderSystem\BuerklinSettings;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class BuerklinProvider implements BatchInfoProviderInterface
class BuerklinProvider implements BatchInfoProviderInterface, URLHandlerInfoProviderInterface
{
private const ENDPOINT_URL = 'https://www.buerklin.com/buerklinws/v2/buerklin';
@@ -365,7 +365,7 @@ class BuerklinProvider implements BatchInfoProviderInterface
* - prefers 'zoom' format, then 'product' format, then all others
*
* @param array|null $images
* @return \App\Services\InfoProviderSystem\DTOs\FileDTO[]
* @return FileDTO[]
*/
private function getProductImages(?array $images): array
{
@@ -636,4 +636,35 @@ class BuerklinProvider implements BatchInfoProviderInterface
);
}
public function getHandledDomains(): array
{
return ['buerklin.com'];
}
public function getIDFromURL(string $url): ?string
{
//Inputs:
//https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/
//https://www.buerklin.com/de/p/40F1332/
//https://www.buerklin.com/en/p/bkl-electronic/dc-connectors/072341-l/40F1332/
//https://www.buerklin.com/en/p/40F1332/
//The ID is the last part after the manufacturer/category/mpn segment and before the final slash
//https://www.buerklin.com/de/p/bkl-electronic/niedervoltsteckverbinder/072341-l/40F1332/#download should also work
$path = parse_url($url, PHP_URL_PATH);
if (!$path) {
return null;
}
// Ensure it's actually a product URL
if (strpos($path, '/p/') === false) {
return null;
}
$id = basename(rtrim($path, '/'));
return $id !== '' && $id !== 'p' ? $id : null;
}
}

View File

@@ -95,7 +95,7 @@ final class LabelHTMLGenerator
'paper_height' => $options->getHeight(),
]
);
} catch (Error $exception) {
} catch (\Throwable $exception) {
throw new TwigModeException($exception);
}
} else {

View File

@@ -70,12 +70,14 @@ use App\Twig\Sandbox\SandboxedLabelExtension;
use App\Twig\TwigCoreExtension;
use InvalidArgumentException;
use Twig\Environment;
use Twig\Extension\AttributeExtension;
use Twig\Extension\SandboxExtension;
use Twig\Extra\Html\HtmlExtension;
use Twig\Extra\Intl\IntlExtension;
use Twig\Extra\Markdown\MarkdownExtension;
use Twig\Extra\String\StringExtension;
use Twig\Loader\ArrayLoader;
use Twig\RuntimeLoader\FactoryRuntimeLoader;
use Twig\Sandbox\SecurityPolicyInterface;
/**
@@ -84,11 +86,11 @@ use Twig\Sandbox\SecurityPolicyInterface;
*/
final class SandboxedTwigFactory
{
private const ALLOWED_TAGS = ['apply', 'autoescape', 'do', 'for', 'if', 'set', 'verbatim', 'with'];
private const ALLOWED_TAGS = ['apply', 'autoescape', 'do', 'for', 'if', 'set', 'types', 'verbatim', 'with'];
private const ALLOWED_FILTERS = ['abs', 'batch', 'capitalize', 'column', 'country_name',
'currency_name', 'currency_symbol', 'date', 'date_modify', 'data_uri', 'default', 'escape', 'filter', 'first', 'format',
'currency_name', 'currency_symbol', 'date', 'date_modify', 'data_uri', 'default', 'escape', 'filter', 'find', 'first', 'format',
'format_currency', 'format_date', 'format_datetime', 'format_number', 'format_time', 'html_to_markdown', 'join', 'keys',
'language_name', 'last', 'length', 'locale_name', 'lower', 'map', 'markdown_to_html', 'merge', 'nl2br', 'raw', 'number_format',
'language_name', 'last', 'length', 'locale_name', 'lower', 'map', 'markdown_to_html', 'merge', 'nl2br', 'number_format', 'raw',
'reduce', 'replace', 'reverse', 'round', 'slice', 'slug', 'sort', 'spaceless', 'split', 'striptags', 'timezone_name', 'title',
'trim', 'u', 'upper', 'url_encode',
@@ -102,16 +104,17 @@ final class SandboxedTwigFactory
];
private const ALLOWED_FUNCTIONS = ['country_names', 'country_timezones', 'currency_names', 'cycle',
'date', 'html_classes', 'language_names', 'locale_names', 'max', 'min', 'random', 'range', 'script_names',
'template_from_string', 'timezone_names',
'date', 'enum', 'enum_cases', 'html_classes', 'language_names', 'locale_names', 'max', 'min', 'random', 'range', 'script_names',
'timezone_names',
//Part-DB specific extensions:
//EntityExtension:
'entity_type', 'entity_url',
'entity_type', 'entity_url', 'type_label', 'type_label_plural',
//BarcodeExtension:
'barcode_svg',
//SandboxedLabelExtension
'placeholder',
'associated_parts', 'associated_parts_count', 'associated_parts_r', 'associated_parts_count_r',
];
private const ALLOWED_METHODS = [
@@ -128,7 +131,7 @@ final class SandboxedTwigFactory
'getValueTypical', 'getUnit', 'getValueText', ],
MeasurementUnit::class => ['getUnit', 'isInteger', 'useSIPrefix'],
PartLot::class => ['isExpired', 'getDescription', 'getComment', 'getExpirationDate', 'getStorageLocation',
'getPart', 'isInstockUnknown', 'getAmount', 'getNeedsRefill', 'getVendorBarcode'],
'getPart', 'isInstockUnknown', 'getAmount', 'getOwner', 'getLastStocktakeAt', 'getNeedsRefill', 'getVendorBarcode'],
StorageLocation::class => ['isFull', 'isOnlySinglePart', 'isLimitToExistingParts', 'getStorageType'],
Supplier::class => ['getShippingCosts', 'getDefaultCurrency'],
Part::class => ['isNeedsReview', 'getTags', 'getMass', 'getIpn', 'getProviderReference',
@@ -139,13 +142,13 @@ final class SandboxedTwigFactory
'getParameters', 'getGroupedParameters',
'isProjectBuildPart', 'getBuiltProject',
'getAssociatedPartsAsOwner', 'getAssociatedPartsAsOther', 'getAssociatedPartsAll',
'getEdaInfo'
'getEdaInfo', 'getGtin'
],
Currency::class => ['getIsoCode', 'getInverseExchangeRate', 'getExchangeRate'],
Orderdetail::class => ['getPart', 'getSupplier', 'getSupplierPartNr', 'getObsolete',
'getPricedetails', 'findPriceForQty', 'isObsolete', 'getSupplierProductUrl'],
'getPricedetails', 'findPriceForQty', 'isObsolete', 'getSupplierProductUrl', 'getPricesIncludesVAT'],
Pricedetail::class => ['getOrderdetail', 'getPrice', 'getPricePerUnit', 'getPriceRelatedQuantity',
'getMinDiscountQuantity', 'getCurrency', 'getCurrencyISOCode'],
'getMinDiscountQuantity', 'getCurrency', 'getCurrencyISOCode', 'getIncludesVat'],
InfoProviderReference:: class => ['getProviderKey', 'getProviderId', 'getProviderUrl', 'getLastUpdated', 'isProviderCreated'],
PartAssociation::class => ['getType', 'getComment', 'getOwner', 'getOther', 'getOtherType'],
@@ -186,13 +189,18 @@ final class SandboxedTwigFactory
$twig->addExtension(new StringExtension());
$twig->addExtension(new HtmlExtension());
//Add Part-DB specific extension
$twig->addExtension($this->formatExtension);
$twig->addExtension($this->barcodeExtension);
$twig->addExtension($this->entityExtension);
$twig->addExtension($this->twigCoreExtension);
$twig->addExtension($this->sandboxedLabelExtension);
//Our other extensions are using attributes, we need a bit more work to load them
$dynamicExtensions = [$this->formatExtension, $this->barcodeExtension, $this->entityExtension, $this->twigCoreExtension];
$dynamicExtensionsMap = [];
foreach ($dynamicExtensions as $extension) {
$twig->addExtension(new AttributeExtension($extension::class));
$dynamicExtensionsMap[$extension::class] = static fn () => $extension;
}
$twig->addRuntimeLoader(new FactoryRuntimeLoader($dynamicExtensionsMap));
return $twig;
}

View File

@@ -32,7 +32,7 @@ class LogDiffFormatter
* @param $old_data
* @param $new_data
*/
public function formatDiff($old_data, $new_data): string
public function formatDiff(mixed $old_data, mixed $new_data): string
{
if (is_string($old_data) && is_string($new_data)) {
return $this->diffString($old_data, $new_data);

View File

@@ -22,6 +22,8 @@ declare(strict_types=1);
namespace App\Services\System;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shivas\VersioningBundle\Service\VersionManagerInterface;
@@ -334,7 +336,7 @@ readonly class BackupManager
$params = $connection->getParams();
$platform = $connection->getDatabasePlatform();
if ($platform instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) {
if ($platform instanceof AbstractMySQLPlatform) {
// Use mysql command to import - need to use shell to handle input redirection
$mysqlCmd = 'mysql';
if (isset($params['host'])) {
@@ -361,7 +363,7 @@ readonly class BackupManager
if (!$process->isSuccessful()) {
throw new \RuntimeException('MySQL import failed: ' . $process->getErrorOutput());
}
} elseif ($platform instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
} elseif ($platform instanceof PostgreSQLPlatform) {
// Use psql command to import
$psqlCmd = 'psql';
if (isset($params['host'])) {

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\LabelGenerationRequest;
use App\Entity\Base\AbstractDBElement;
use App\Entity\LabelSystem\LabelProfile;
use App\Repository\DBElementRepository;
use App\Repository\LabelProfileRepository;
use App\Services\ElementTypeNameGenerator;
use App\Services\LabelSystem\LabelGenerator;
use App\Services\Misc\RangeParser;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class LabelGenerationProcessor implements ProcessorInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LabelGenerator $labelGenerator,
private readonly RangeParser $rangeParser,
private readonly ElementTypeNameGenerator $elementTypeNameGenerator,
private readonly Security $security,
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Response
{
// Check if user has permission to create labels
if (!$this->security->isGranted('@labels.create_labels')) {
throw new AccessDeniedHttpException('You do not have permission to generate labels.');
}
if (!$data instanceof LabelGenerationRequest) {
throw new BadRequestHttpException('Invalid request data for label generation.');
}
/** @var LabelGenerationRequest $request */
$request = $data;
// Fetch the label profile
/** @var LabelProfileRepository<LabelProfile> $profileRepo */
$profileRepo = $this->entityManager->getRepository(LabelProfile::class);
$profile = $profileRepo->find($request->profileId);
if (!$profile instanceof LabelProfile) {
throw new NotFoundHttpException(sprintf('Label profile with ID %d not found.', $request->profileId));
}
// Check if user has read permission for the profile
if (!$this->security->isGranted('read', $profile)) {
throw new AccessDeniedHttpException('You do not have permission to access this label profile.');
}
// Get label options from profile
$options = $profile->getOptions();
// Override element type if provided, otherwise use profile's default
if ($request->elementType !== null) {
$options->setSupportedElement($request->elementType);
}
// Parse element IDs from the range string
try {
$idArray = $this->rangeParser->parse($request->elementIds);
} catch (\InvalidArgumentException $e) {
throw new BadRequestHttpException('Invalid element IDs format: ' . $e->getMessage());
}
if (empty($idArray)) {
throw new BadRequestHttpException('No valid element IDs provided.');
}
// Fetch the target entities
/** @var DBElementRepository<AbstractDBElement> $repo */
$repo = $this->entityManager->getRepository($options->getSupportedElement()->getEntityClass());
$elements = $repo->getElementsFromIDArray($idArray);
if (empty($elements)) {
throw new NotFoundHttpException('No elements found with the provided IDs.');
}
// Generate the PDF
try {
$pdfContent = $this->labelGenerator->generateLabel($options, $elements);
} catch (\Exception $e) {
throw new BadRequestHttpException('Failed to generate label: ' . $e->getMessage());
}
// Generate filename
$filename = $this->generateFilename($elements[0], $profile);
// Return PDF as response
return new Response(
$pdfContent,
Response::HTTP_OK,
[
'Content-Type' => 'application/pdf',
'Content-Disposition' => sprintf('attachment; filename="%s"', $filename),
'Content-Length' => (string) strlen($pdfContent),
]
);
}
private function generateFilename(AbstractDBElement $element, LabelProfile $profile): string
{
$ret = 'label_' . $this->elementTypeNameGenerator->typeLabel($element);
$ret .= $element->getID();
$ret .= '_' . preg_replace('/[^a-z0-9_\-]/i', '_', $profile->getName());
return $ret . '.pdf';
}
}

View File

@@ -25,22 +25,34 @@ namespace App\Twig;
use App\Entity\Attachments\Attachment;
use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Misc\FAIconGenerator;
use Twig\Attribute\AsTwigFunction;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
final class AttachmentExtension extends AbstractExtension
final readonly class AttachmentExtension
{
public function __construct(protected AttachmentURLGenerator $attachmentURLGenerator, protected FAIconGenerator $FAIconGenerator)
public function __construct(private AttachmentURLGenerator $attachmentURLGenerator, private FAIconGenerator $FAIconGenerator)
{
}
public function getFunctions(): array
/**
* Returns the URL of the thumbnail of the given attachment. Returns null if no thumbnail is available.
*/
#[AsTwigFunction("attachment_thumbnail")]
public function attachmentThumbnail(Attachment $attachment, string $filter_name = 'thumbnail_sm'): ?string
{
return [
/* Returns the URL to a thumbnail of the given attachment */
new TwigFunction('attachment_thumbnail', fn(Attachment $attachment, string $filter_name = 'thumbnail_sm'): ?string => $this->attachmentURLGenerator->getThumbnailURL($attachment, $filter_name)),
/* Returns the font awesome icon class which is representing the given file extension (We allow null here for attachments without extension) */
new TwigFunction('ext_to_fa_icon', fn(?string $extension): string => $this->FAIconGenerator->fileExtensionToFAType($extension ?? '')),
];
return $this->attachmentURLGenerator->getThumbnailURL($attachment, $filter_name);
}
/**
* Return the font-awesome icon type for the given file extension. Returns "file" if no specific icon is available.
* Null is allowed for files withot extension
* @param string|null $extension
* @return string
*/
#[AsTwigFunction("ext_to_fa_icon")]
public function extToFAIcon(?string $extension): string
{
return $this->FAIconGenerator->fileExtensionToFAType($extension ?? '');
}
}

View File

@@ -23,19 +23,14 @@ declare(strict_types=1);
namespace App\Twig;
use Com\Tecnick\Barcode\Barcode;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
use Twig\Attribute\AsTwigFunction;
final class BarcodeExtension extends AbstractExtension
final class BarcodeExtension
{
public function getFunctions(): array
{
return [
/* Generates a barcode with the given Type and Data and returns it as an SVG represenation */
new TwigFunction('barcode_svg', fn(string $content, string $type = 'QRCODE'): string => $this->barcodeSVG($content, $type)),
];
}
/**
* Generates a barcode in SVG format for the given content and type.
*/
#[AsTwigFunction('barcode_svg')]
public function barcodeSVG(string $content, string $type = 'QRCODE'): string
{
$barcodeFactory = new Barcode();

View File

@@ -41,6 +41,9 @@ use App\Exceptions\EntityNotSupportedException;
use App\Services\ElementTypeNameGenerator;
use App\Services\EntityURLGenerator;
use App\Services\Trees\TreeViewGenerator;
use Twig\Attribute\AsTwigFunction;
use Twig\Attribute\AsTwigTest;
use Twig\DeprecatedCallableInfo;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
use Twig\TwigTest;
@@ -48,61 +51,27 @@ use Twig\TwigTest;
/**
* @see \App\Tests\Twig\EntityExtensionTest
*/
final class EntityExtension extends AbstractExtension
final readonly class EntityExtension
{
public function __construct(protected EntityURLGenerator $entityURLGenerator, protected TreeViewGenerator $treeBuilder, private readonly ElementTypeNameGenerator $nameGenerator)
public function __construct(private EntityURLGenerator $entityURLGenerator, private TreeViewGenerator $treeBuilder, private ElementTypeNameGenerator $nameGenerator)
{
}
public function getTests(): array
/**
* Checks if the given variable is an entity (instance of AbstractDBElement).
*/
#[AsTwigTest("entity")]
public function entityTest(mixed $var): bool
{
return [
/* Checks if the given variable is an entitity (instance of AbstractDBElement) */
new TwigTest('entity', static fn($var) => $var instanceof AbstractDBElement),
];
return $var instanceof AbstractDBElement;
}
public function getFunctions(): array
{
return [
/* Returns a string representation of the given entity */
new TwigFunction('entity_type', fn(object $entity): ?string => $this->getEntityType($entity)),
/* Returns the URL to the given entity */
new TwigFunction('entity_url', fn(AbstractDBElement $entity, string $method = 'info'): string => $this->generateEntityURL($entity, $method)),
/* Returns the URL to the given entity in timetravel mode */
new TwigFunction('timetravel_url', fn(AbstractDBElement $element, \DateTimeInterface $dateTime): ?string => $this->timeTravelURL($element, $dateTime)),
/* Generates a JSON array of the given tree */
new TwigFunction('tree_data', fn(AbstractDBElement $element, string $type = 'newEdit'): string => $this->treeData($element, $type)),
/* Gets a human readable label for the type of the given entity */
new TwigFunction('entity_type_label', fn(object|string $entity): string => $this->nameGenerator->getLocalizedTypeLabel($entity)),
new TwigFunction('type_label', fn(object|string $entity): string => $this->nameGenerator->typeLabel($entity)),
new TwigFunction('type_label_p', fn(object|string $entity): string => $this->nameGenerator->typeLabelPlural($entity)),
];
}
public function timeTravelURL(AbstractDBElement $element, \DateTimeInterface $dateTime): ?string
{
try {
return $this->entityURLGenerator->timeTravelURL($element, $dateTime);
} catch (EntityNotSupportedException) {
return null;
}
}
public function treeData(AbstractDBElement $element, string $type = 'newEdit'): string
{
$tree = $this->treeBuilder->getTreeView($element::class, null, $type, $element);
return json_encode($tree, JSON_THROW_ON_ERROR);
}
public function generateEntityURL(AbstractDBElement $entity, string $method = 'info'): string
{
return $this->entityURLGenerator->getURL($entity, $method);
}
public function getEntityType(object $entity): ?string
/**
* Returns a string representation of the given entity
*/
#[AsTwigFunction("entity_type")]
public function entityType(object $entity): ?string
{
$map = [
Part::class => 'part',
@@ -129,4 +98,69 @@ final class EntityExtension extends AbstractExtension
return null;
}
/**
* Returns the URL for the given entity and method. E.g. for a Part and method "edit", it will return the URL to edit this part.
*/
#[AsTwigFunction("entity_url")]
public function entityURL(AbstractDBElement $entity, string $method = 'info'): string
{
return $this->entityURLGenerator->getURL($entity, $method);
}
/**
* Returns the URL for the given entity in timetravel mode.
*/
#[AsTwigFunction("timetravel_url")]
public function timeTravelURL(AbstractDBElement $element, \DateTimeInterface $dateTime): ?string
{
try {
return $this->entityURLGenerator->timeTravelURL($element, $dateTime);
} catch (EntityNotSupportedException) {
return null;
}
}
/**
* Generates a tree data structure for the given element, which can be used to display a tree view of the element and its related entities.
* The type parameter can be used to specify the type of tree view (e.g. "newEdit" for the tree view in the new/edit pages). The returned data is a JSON string.
*/
#[AsTwigFunction("tree_data")]
public function treeData(AbstractDBElement $element, string $type = 'newEdit'): string
{
$tree = $this->treeBuilder->getTreeView($element::class, null, $type, $element);
return json_encode($tree, JSON_THROW_ON_ERROR);
}
/**
* Gets the localized type label for the given entity. E.g. for a Part, it will return "Part" in English and "Bauteil" in German.
* @deprecated Use the "type_label" function instead, which does the same but is more concise.
*/
#[AsTwigFunction("entity_type_label", deprecationInfo: new DeprecatedCallableInfo("Part-DB", "2", "Use the 'type_label' function instead."))]
public function entityTypeLabel(object|string $entity): string
{
return $this->nameGenerator->getLocalizedTypeLabel($entity);
}
/**
* Gets the localized type label for the given entity. E.g. for a Part, it will return "Part" in English and "Bauteil" in German.
*/
#[AsTwigFunction("type_label")]
public function typeLabel(object|string $entity): string
{
return $this->nameGenerator->typeLabel($entity);
}
/**
* Gets the localized plural type label for the given entity. E.g. for a Part, it will return "Parts" in English and "Bauteile" in German.
* @param object|string $entity
* @return string
*/
#[AsTwigFunction("type_label_p")]
public function typeLabelPlural(object|string $entity): string
{
return $this->nameGenerator->typeLabelPlural($entity);
}
}

View File

@@ -29,35 +29,28 @@ use App\Services\Formatters\MarkdownParser;
use App\Services\Formatters\MoneyFormatter;
use App\Services\Formatters\SIFormatter;
use Brick\Math\BigDecimal;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\Attribute\AsTwigFilter;
final class FormatExtension extends AbstractExtension
final readonly class FormatExtension
{
public function __construct(protected MarkdownParser $markdownParser, protected MoneyFormatter $moneyFormatter, protected SIFormatter $siformatter, protected AmountFormatter $amountFormatter)
public function __construct(private MarkdownParser $markdownParser, private MoneyFormatter $moneyFormatter, private SIFormatter $siformatter, private AmountFormatter $amountFormatter)
{
}
public function getFilters(): array
/**
* Mark the given text as markdown, which will be rendered in the browser
*/
#[AsTwigFilter("format_markdown", isSafe: ['html'], preEscape: 'html')]
public function formatMarkdown(string $markdown, bool $inline_mode = false): string
{
return [
/* Mark the given text as markdown, which will be rendered in the browser */
new TwigFilter('format_markdown', fn(string $markdown, bool $inline_mode = false): string => $this->markdownParser->markForRendering($markdown, $inline_mode), [
'pre_escape' => 'html',
'is_safe' => ['html'],
]),
/* Format the given amount as money, using a given currency */
new TwigFilter('format_money', fn($amount, ?Currency $currency = null, int $decimals = 5): string => $this->formatCurrency($amount, $currency, $decimals)),
/* Format the given number using SI prefixes and the given unit (string) */
new TwigFilter('format_si', fn($value, $unit, $decimals = 2, bool $show_all_digits = false): string => $this->siFormat($value, $unit, $decimals, $show_all_digits)),
/** Format the given amount using the given MeasurementUnit */
new TwigFilter('format_amount', fn($value, ?MeasurementUnit $unit, array $options = []): string => $this->amountFormat($value, $unit, $options)),
/** Format the given number of bytes as human-readable number */
new TwigFilter('format_bytes', fn(int $bytes, int $precision = 2): string => $this->formatBytes($bytes, $precision)),
];
return $this->markdownParser->markForRendering($markdown, $inline_mode);
}
public function formatCurrency($amount, ?Currency $currency = null, int $decimals = 5): string
/**
* Format the given amount as money, using a given currency
*/
#[AsTwigFilter("format_money")]
public function formatMoney(BigDecimal|float|string $amount, ?Currency $currency = null, int $decimals = 5): string
{
if ($amount instanceof BigDecimal) {
$amount = (string) $amount;
@@ -66,19 +59,22 @@ final class FormatExtension extends AbstractExtension
return $this->moneyFormatter->format($amount, $currency, $decimals);
}
public function siFormat($value, $unit, $decimals = 2, bool $show_all_digits = false): string
/**
* Format the given number using SI prefixes and the given unit (string)
*/
#[AsTwigFilter("format_si")]
public function siFormat(float $value, string $unit, int $decimals = 2, bool $show_all_digits = false): string
{
return $this->siformatter->format($value, $unit, $decimals);
}
public function amountFormat($value, ?MeasurementUnit $unit, array $options = []): string
#[AsTwigFilter("format_amount")]
public function amountFormat(float|int|string $value, ?MeasurementUnit $unit, array $options = []): string
{
return $this->amountFormatter->format($value, $unit, $options);
}
/**
* @param $bytes
*/
#[AsTwigFilter("format_bytes")]
public function formatBytes(int $bytes, int $precision = 2): string
{
$size = ['B','kB','MB','GB','TB','PB','EB','ZB','YB'];

View File

@@ -23,31 +23,25 @@ declare(strict_types=1);
namespace App\Twig;
use Twig\Attribute\AsTwigFunction;
use App\Services\InfoProviderSystem\ProviderRegistry;
use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class InfoProviderExtension extends AbstractExtension
final readonly class InfoProviderExtension
{
public function __construct(
private readonly ProviderRegistry $providerRegistry
private ProviderRegistry $providerRegistry
) {}
public function getFunctions(): array
{
return [
new TwigFunction('info_provider', $this->getInfoProvider(...)),
new TwigFunction('info_provider_label', $this->getInfoProviderName(...))
];
}
/**
* Gets the info provider with the given key. Returns null, if the provider does not exist.
* @param string $key
* @return InfoProviderInterface|null
*/
private function getInfoProvider(string $key): ?InfoProviderInterface
#[AsTwigFunction(name: 'info_provider')]
public function getInfoProvider(string $key): ?InfoProviderInterface
{
try {
return $this->providerRegistry->getProviderByKey($key);
@@ -61,7 +55,8 @@ class InfoProviderExtension extends AbstractExtension
* @param string $key
* @return string|null
*/
private function getInfoProviderName(string $key): ?string
#[AsTwigFunction(name: 'info_provider_label')]
public function getInfoProviderName(string $key): ?string
{
try {
return $this->providerRegistry->getProviderByKey($key)->getProviderInfo()['name'];
@@ -69,4 +64,4 @@ class InfoProviderExtension extends AbstractExtension
return null;
}
}
}
}

View File

@@ -25,21 +25,26 @@ namespace App\Twig;
use App\Entity\LogSystem\AbstractLogEntry;
use App\Services\LogSystem\LogDataFormatter;
use App\Services\LogSystem\LogDiffFormatter;
use Twig\Attribute\AsTwigFunction;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
final class LogExtension extends AbstractExtension
final readonly class LogExtension
{
public function __construct(private readonly LogDataFormatter $logDataFormatter, private readonly LogDiffFormatter $logDiffFormatter)
public function __construct(private LogDataFormatter $logDataFormatter, private LogDiffFormatter $logDiffFormatter)
{
}
public function getFunctions(): array
#[AsTwigFunction(name: 'format_log_data', isSafe: ['html'])]
public function formatLogData(mixed $data, AbstractLogEntry $logEntry, string $fieldName): string
{
return [
new TwigFunction('format_log_data', fn($data, AbstractLogEntry $logEntry, string $fieldName): string => $this->logDataFormatter->formatData($data, $logEntry, $fieldName), ['is_safe' => ['html']]),
new TwigFunction('format_log_diff', fn($old_data, $new_data): string => $this->logDiffFormatter->formatDiff($old_data, $new_data), ['is_safe' => ['html']]),
];
return $this->logDataFormatter->formatData($data, $logEntry, $fieldName);
}
#[AsTwigFunction(name: 'format_log_diff', isSafe: ['html'])]
public function formatLogDiff(mixed $old_data, mixed $new_data): string
{
return $this->logDiffFormatter->formatDiff($old_data, $new_data);
}
}

View File

@@ -22,6 +22,7 @@ declare(strict_types=1);
*/
namespace App\Twig;
use Twig\Attribute\AsTwigFunction;
use App\Settings\SettingsIcon;
use Symfony\Component\HttpFoundation\Request;
use App\Services\LogSystem\EventCommentType;
@@ -31,23 +32,14 @@ use Twig\TwigFunction;
use App\Services\LogSystem\EventCommentNeededHelper;
use Twig\Extension\AbstractExtension;
final class MiscExtension extends AbstractExtension
final readonly class MiscExtension
{
public function __construct(private readonly EventCommentNeededHelper $eventCommentNeededHelper)
public function __construct(private EventCommentNeededHelper $eventCommentNeededHelper)
{
}
public function getFunctions(): array
{
return [
new TwigFunction('event_comment_needed', $this->evenCommentNeeded(...)),
new TwigFunction('settings_icon', $this->settingsIcon(...)),
new TwigFunction('uri_without_host', $this->uri_without_host(...))
];
}
private function evenCommentNeeded(string|EventCommentType $operation_type): bool
#[AsTwigFunction(name: 'event_comment_needed')]
public function evenCommentNeeded(string|EventCommentType $operation_type): bool
{
if (is_string($operation_type)) {
$operation_type = EventCommentType::from($operation_type);
@@ -63,7 +55,8 @@ final class MiscExtension extends AbstractExtension
* @return string|null
* @throws \ReflectionException
*/
private function settingsIcon(string|object $objectOrClass): ?string
#[AsTwigFunction(name: 'settings_icon')]
public function settingsIcon(string|object $objectOrClass): ?string
{
//If the given object is a proxy, then get the real object
if (is_a($objectOrClass, SettingsProxyInterface::class)) {
@@ -82,6 +75,7 @@ final class MiscExtension extends AbstractExtension
* @param Request $request
* @return string
*/
#[AsTwigFunction(name: 'uri_without_host')]
public function uri_without_host(Request $request): string
{
if (null !== $qs = $request->getQueryString()) {

View File

@@ -23,14 +23,18 @@ declare(strict_types=1);
namespace App\Twig\Sandbox;
use App\Entity\Base\AbstractPartsContainingDBElement;
use App\Entity\Parts\Part;
use App\Repository\AbstractPartsContainingRepository;
use App\Services\LabelSystem\LabelTextReplacer;
use Doctrine\ORM\EntityManagerInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
class SandboxedLabelExtension extends AbstractExtension
{
public function __construct(private readonly LabelTextReplacer $labelTextReplacer)
public function __construct(private readonly LabelTextReplacer $labelTextReplacer, private readonly EntityManagerInterface $em)
{
}
@@ -39,6 +43,11 @@ class SandboxedLabelExtension extends AbstractExtension
{
return [
new TwigFunction('placeholder', fn(string $text, object $label_target) => $this->labelTextReplacer->handlePlaceholderOrReturnNull($text, $label_target)),
new TwigFunction("associated_parts", $this->associatedParts(...)),
new TwigFunction("associated_parts_count", $this->associatedPartsCount(...)),
new TwigFunction("associated_parts_r", $this->associatedPartsRecursive(...)),
new TwigFunction("associated_parts_count_r", $this->associatedPartsCountRecursive(...)),
];
}
@@ -48,4 +57,37 @@ class SandboxedLabelExtension extends AbstractExtension
new TwigFilter('placeholders', fn(string $text, object $label_target) => $this->labelTextReplacer->replace($text, $label_target)),
];
}
}
/**
* Returns all parts associated with the given element.
* @param AbstractPartsContainingDBElement $element
* @return Part[]
*/
public function associatedParts(AbstractPartsContainingDBElement $element): array
{
/** @var AbstractPartsContainingRepository<AbstractPartsContainingDBElement> $repo */
$repo = $this->em->getRepository($element::class);
return $repo->getParts($element);
}
public function associatedPartsCount(AbstractPartsContainingDBElement $element): int
{
/** @var AbstractPartsContainingRepository<AbstractPartsContainingDBElement> $repo */
$repo = $this->em->getRepository($element::class);
return $repo->getPartsCount($element);
}
public function associatedPartsRecursive(AbstractPartsContainingDBElement $element): array
{
/** @var AbstractPartsContainingRepository<AbstractPartsContainingDBElement> $repo */
$repo = $this->em->getRepository($element::class);
return $repo->getPartsRecursive($element);
}
public function associatedPartsCountRecursive(AbstractPartsContainingDBElement $element): int
{
/** @var AbstractPartsContainingRepository<AbstractPartsContainingDBElement> $repo */
$repo = $this->em->getRepository($element::class);
return $repo->getPartsCountRecursive($element);
}
}

View File

@@ -22,7 +22,11 @@ declare(strict_types=1);
*/
namespace App\Twig;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Twig\Attribute\AsTwigFilter;
use Twig\Attribute\AsTwigFunction;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Twig\Attribute\AsTwigTest;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
@@ -32,58 +36,54 @@ use Twig\TwigTest;
* The functionalities here extend the Twig with some core functions, which are independently of Part-DB.
* @see \App\Tests\Twig\TwigCoreExtensionTest
*/
final class TwigCoreExtension extends AbstractExtension
final readonly class TwigCoreExtension
{
private readonly ObjectNormalizer $objectNormalizer;
private NormalizerInterface $objectNormalizer;
public function __construct()
{
$this->objectNormalizer = new ObjectNormalizer();
}
public function getFunctions(): array
/**
* Checks if the given variable is an instance of the given class/interface/enum. E.g. `x is instanceof('App\Entity\Parts\Part')`
* @param mixed $var
* @param string $instance
* @return bool
*/
#[AsTwigTest("instanceof")]
public function testInstanceOf(mixed $var, string $instance): bool
{
return [
/* Returns the enum cases as values */
new TwigFunction('enum_cases', $this->getEnumCases(...)),
];
}
if (!class_exists($instance) && !interface_exists($instance) && !enum_exists($instance)) {
throw new \InvalidArgumentException(sprintf('The given class/interface/enum "%s" does not exist!', $instance));
}
public function getTests(): array
{
return [
/*
* Checks if a given variable is an instance of a given class. E.g. ` x is instanceof('App\Entity\Parts\Part')`
*/
new TwigTest('instanceof', static fn($var, $instance) => $var instanceof $instance),
/* Checks if a given variable is an object. E.g. `x is object` */
new TwigTest('object', static fn($var): bool => is_object($var)),
new TwigTest('enum', fn($var) => $var instanceof \UnitEnum),
];
return $var instanceof $instance;
}
/**
* @param string $enum_class
* @phpstan-param class-string $enum_class
* Checks if the given variable is an object. This can be used to check if a variable is an object, without knowing the exact class of the object. E.g. `x is object`
* @param mixed $var
* @return bool
*/
public function getEnumCases(string $enum_class): array
#[AsTwigTest("object")]
public function testObject(mixed $var): bool
{
if (!enum_exists($enum_class)) {
throw new \InvalidArgumentException(sprintf('The given class "%s" is not an enum!', $enum_class));
}
/** @noinspection PhpUndefinedMethodInspection */
return ($enum_class)::cases();
return is_object($var);
}
public function getFilters(): array
/**
* Checks if the given variable is an enum (instance of UnitEnum). This can be used to check if a variable is an enum, without knowing the exact class of the enum. E.g. `x is enum`
* @param mixed $var
* @return bool
*/
#[AsTwigTest("enum")]
public function testEnum(mixed $var): bool
{
return [
/* Converts the given object to an array representation of the public/accessible properties */
new TwigFilter('to_array', fn($object) => $this->toArray($object)),
];
return $var instanceof \UnitEnum;
}
#[AsTwigFilter('to_array')]
public function toArray(object|array $object): array
{
//If it is already an array, we can just return it

View File

@@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Twig;
use Twig\Attribute\AsTwigFunction;
use App\Services\System\UpdateAvailableFacade;
use Symfony\Bundle\SecurityBundle\Security;
use Twig\Extension\AbstractExtension;
@@ -31,26 +32,18 @@ use Twig\TwigFunction;
/**
* Twig extension for update-related functions.
*/
final class UpdateExtension extends AbstractExtension
final readonly class UpdateExtension
{
public function __construct(private readonly UpdateAvailableFacade $updateAvailableManager,
private readonly Security $security)
public function __construct(private UpdateAvailableFacade $updateAvailableManager,
private Security $security)
{
}
public function getFunctions(): array
{
return [
new TwigFunction('is_update_available', $this->isUpdateAvailable(...)),
new TwigFunction('get_latest_version', $this->getLatestVersion(...)),
new TwigFunction('get_latest_version_url', $this->getLatestVersionUrl(...)),
];
}
/**
* Check if an update is available and the user has permission to see it.
*/
#[AsTwigFunction(name: 'is_update_available')]
public function isUpdateAvailable(): bool
{
// Only show to users with the show_updates permission
@@ -64,6 +57,7 @@ final class UpdateExtension extends AbstractExtension
/**
* Get the latest available version string.
*/
#[AsTwigFunction(name: 'get_latest_version')]
public function getLatestVersion(): string
{
return $this->updateAvailableManager->getLatestVersionString();
@@ -72,6 +66,7 @@ final class UpdateExtension extends AbstractExtension
/**
* Get the URL to the latest version release page.
*/
#[AsTwigFunction(name: 'get_latest_version_url')]
public function getLatestVersionUrl(): string
{
return $this->updateAvailableManager->getLatestVersionUrl();

View File

@@ -41,51 +41,24 @@ declare(strict_types=1);
namespace App\Twig;
use App\Entity\Base\AbstractDBElement;
use Twig\Attribute\AsTwigFilter;
use Twig\Attribute\AsTwigFunction;
use App\Entity\UserSystem\User;
use App\Entity\LogSystem\AbstractLogEntry;
use App\Repository\LogEntryRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
/**
* @see \App\Tests\Twig\UserExtensionTest
*/
final class UserExtension extends AbstractExtension
final readonly class UserExtension
{
private readonly LogEntryRepository $repo;
public function __construct(EntityManagerInterface $em,
private readonly Security $security,
private readonly UrlGeneratorInterface $urlGenerator)
public function __construct(
private Security $security,
private UrlGeneratorInterface $urlGenerator)
{
$this->repo = $em->getRepository(AbstractLogEntry::class);
}
public function getFilters(): array
{
return [
new TwigFilter('remove_locale_from_path', fn(string $path): string => $this->removeLocaleFromPath($path)),
];
}
public function getFunctions(): array
{
return [
/* Returns the user which has edited the given entity the last time. */
new TwigFunction('last_editing_user', fn(AbstractDBElement $element): ?User => $this->repo->getLastEditingUser($element)),
/* Returns the user which has created the given entity. */
new TwigFunction('creating_user', fn(AbstractDBElement $element): ?User => $this->repo->getCreatingUser($element)),
new TwigFunction('impersonator_user', $this->getImpersonatorUser(...)),
new TwigFunction('impersonation_active', $this->isImpersonationActive(...)),
new TwigFunction('impersonation_path', $this->getImpersonationPath(...)),
];
}
/**
@@ -93,6 +66,7 @@ final class UserExtension extends AbstractExtension
* If the current user is not impersonated, null is returned.
* @return User|null
*/
#[AsTwigFunction(name: 'impersonator_user')]
public function getImpersonatorUser(): ?User
{
$token = $this->security->getToken();
@@ -107,11 +81,13 @@ final class UserExtension extends AbstractExtension
return null;
}
#[AsTwigFunction(name: 'impersonation_active')]
public function isImpersonationActive(): bool
{
return $this->security->isGranted('IS_IMPERSONATOR');
}
#[AsTwigFunction(name: 'impersonation_path')]
public function getImpersonationPath(User $user, string $route_name = 'homepage'): string
{
if (! $this->security->isGranted('CAN_SWITCH_USER', $user)) {
@@ -124,6 +100,7 @@ final class UserExtension extends AbstractExtension
/**
* This function/filter generates a path.
*/
#[AsTwigFilter(name: 'remove_locale_from_path')]
public function removeLocaleFromPath(string $path): string
{
//Ensure the path has the correct format

View File

@@ -0,0 +1,57 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 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\Twig;
use App\Entity\Base\AbstractDBElement;
use App\Entity\LogSystem\AbstractLogEntry;
use App\Entity\UserSystem\User;
use App\Repository\LogEntryRepository;
use Doctrine\ORM\EntityManagerInterface;
use Twig\Attribute\AsTwigFunction;
final readonly class UserRepoExtension
{
public function __construct(private EntityManagerInterface $entityManager)
{
}
/**
* Returns the user which has edited the given entity the last time.
*/
#[AsTwigFunction('creating_user')]
public function creatingUser(AbstractDBElement $element): ?User
{
return $this->entityManager->getRepository(AbstractLogEntry::class)->getCreatingUser($element);
}
/**
* Returns the user which has edited the given entity the last time.
*/
#[AsTwigFunction('last_editing_user')]
public function lastEditingUser(AbstractDBElement $element): ?User
{
return $this->entityManager->getRepository(AbstractLogEntry::class)->getLastEditingUser($element);
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints;
use Attribute;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints;
use App\Entity\Parts\Part;

View File

@@ -155,3 +155,8 @@
{{- parent() -}}
{% endif %}
{% endblock %}
{% block boolean_constraint_widget %}
{{ form_widget(form.value) }}
{{ form_errors(form.value) }}
{% endblock %}

View File

@@ -1,11 +1,7 @@
{{ form_row(form.eda_info.reference_prefix) }}
{{ form_row(form.eda_info.value) }}
<div class="row">
<div class="col-sm-9 offset-sm-3">
{{ form_row(form.eda_info.visibility) }}
</div>
</div>
{{ form_row(form.eda_info.visibility) }}
<div class="row mb-2">
<div class="col-sm-9 offset-sm-3">
@@ -21,4 +17,4 @@
</div>
</div>
{{ form_row(form.eda_info.kicad_symbol) }}
{{ form_row(form.eda_info.kicad_footprint) }}
{{ form_row(form.eda_info.kicad_footprint) }}

View File

@@ -84,7 +84,7 @@
</span>
</div>
{% if entity is instanceof("App\\Entity\\Parts\\Storelocation") %}
{% if entity is instanceof("App\\Entity\\Parts\\StorageLocation") %}
{{ dropdown.profile_dropdown('storelocation', entity.id, true, 'btn-secondary w-100 mt-2') }}
{% endif %}
@@ -136,4 +136,4 @@
{% if filterForm is defined %}
{% include "parts/lists/_filter.html.twig" %}
{% endif %}
</div>
</div>

View File

@@ -28,7 +28,7 @@ use App\Entity\UserSystem\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class APIDocsAvailabilityTest extends WebTestCase
final class APIDocsAvailabilityTest extends WebTestCase
{
#[DataProvider('urlProvider')]
public function testDocAvailabilityForLoggedInUser(string $url): void

View File

@@ -27,7 +27,7 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\DataFixtures\APITokenFixtures;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use ApiPlatform\Symfony\Bundle\Test\Client;
class APITokenAuthenticationTest extends ApiTestCase
final class APITokenAuthenticationTest extends ApiTestCase
{
public function testUnauthenticated(): void
{

View File

@@ -25,7 +25,7 @@ namespace App\Tests\API\Endpoints;
use App\Tests\API\AuthenticatedApiTestCase;
class ApiTokenEnpointTest extends AuthenticatedApiTestCase
final class ApiTokenEnpointTest extends AuthenticatedApiTestCase
{
public function testGetCurrentToken(): void
{

View File

@@ -25,7 +25,7 @@ namespace App\Tests\API\Endpoints;
use App\Tests\API\Endpoints\CrudEndpointTestCase;
class AttachmentTypeEndpointTest extends CrudEndpointTestCase
final class AttachmentTypeEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string

View File

@@ -25,7 +25,7 @@ namespace App\Tests\API\Endpoints;
use App\Tests\API\AuthenticatedApiTestCase;
class AttachmentsEndpointTest extends AuthenticatedApiTestCase
final class AttachmentsEndpointTest extends AuthenticatedApiTestCase
{
public function testGetCollection(): void
{

View File

@@ -25,7 +25,7 @@ namespace App\Tests\API\Endpoints;
use App\Tests\API\Endpoints\CrudEndpointTestCase;
class CategoryEndpointTest extends CrudEndpointTestCase
final class CategoryEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string

View File

@@ -24,7 +24,7 @@ declare(strict_types=1);
namespace App\Tests\API\Endpoints;
class CurrencyEndpointTest extends CrudEndpointTestCase
final class CurrencyEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string

View File

@@ -25,7 +25,7 @@ namespace App\Tests\API\Endpoints;
use App\Tests\API\Endpoints\CrudEndpointTestCase;
class FootprintsEndpointTest extends CrudEndpointTestCase
final class FootprintsEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string

View File

@@ -25,7 +25,7 @@ namespace API\Endpoints;
use App\Tests\API\AuthenticatedApiTestCase;
class InfoEndpointTest extends AuthenticatedApiTestCase
final class InfoEndpointTest extends AuthenticatedApiTestCase
{
public function testGetInfo(): void
{

View File

@@ -0,0 +1,186 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 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\Tests\API\Endpoints;
use App\Tests\API\AuthenticatedApiTestCase;
class LabelEndpointTest extends AuthenticatedApiTestCase
{
public function testGetLabelProfiles(): void
{
$response = self::createAuthenticatedClient()->request('GET', '/api/label_profiles');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
// Check that we get an array of label profiles
$json = $response->toArray();
self::assertIsArray($json['hydra:member']);
self::assertNotEmpty($json['hydra:member']);
// Check the structure of the first profile
$firstProfile = $json['hydra:member'][0];
self::assertArrayHasKey('@id', $firstProfile);
self::assertArrayHasKey('name', $firstProfile);
self::assertArrayHasKey('options', $firstProfile);
self::assertArrayHasKey('show_in_dropdown', $firstProfile);
}
public function testGetSingleLabelProfile(): void
{
$response = self::createAuthenticatedClient()->request('GET', '/api/label_profiles/1');
self::assertResponseIsSuccessful();
self::assertJsonContains([
'@id' => '/api/label_profiles/1',
]);
$json = $response->toArray();
self::assertArrayHasKey('name', $json);
self::assertArrayHasKey('options', $json);
// Note: options is serialized but individual fields like width/height
// are only available in 'extended' or 'full' serialization groups
self::assertIsArray($json['options']);
}
public function testFilterLabelProfilesByElementType(): void
{
$response = self::createAuthenticatedClient()->request('GET', '/api/label_profiles?options.supported_element=part');
self::assertResponseIsSuccessful();
$json = $response->toArray();
// Check that we get results - the filter should work even if the field isn't in response
self::assertIsArray($json['hydra:member']);
// verify we got profiles
self::assertNotEmpty($json['hydra:member']);
}
public function testGenerateLabelPdf(): void
{
$response = self::createAuthenticatedClient()->request('POST', '/api/labels/generate', [
'json' => [
'profileId' => 1,
'elementIds' => '1',
],
]);
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('content-type', 'application/pdf');
// Check that the response contains PDF data
$content = $response->getContent();
self::assertStringStartsWith('%PDF-', $content);
// Check Content-Disposition header contains attachment and .pdf
$headers = $response->getHeaders();
self::assertArrayHasKey('content-disposition', $headers);
$disposition = $headers['content-disposition'][0];
self::assertStringContainsString('attachment', $disposition);
self::assertStringContainsString('.pdf', $disposition);
}
public function testGenerateLabelPdfWithMultipleElements(): void
{
$response = self::createAuthenticatedClient()->request('POST', '/api/labels/generate', [
'json' => [
'profileId' => 1,
'elementIds' => '1,2,3',
],
]);
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('content-type', 'application/pdf');
self::assertStringStartsWith('%PDF-', $response->getContent());
}
public function testGenerateLabelPdfWithRange(): void
{
$response = self::createAuthenticatedClient()->request('POST', '/api/labels/generate', [
'json' => [
'profileId' => 1,
'elementIds' => '1-3',
],
]);
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('content-type', 'application/pdf');
self::assertStringStartsWith('%PDF-', $response->getContent());
}
public function testGenerateLabelPdfWithInvalidProfileId(): void
{
self::createAuthenticatedClient()->request('POST', '/api/labels/generate', [
'json' => [
'profileId' => 99999,
'elementIds' => '1',
],
]);
self::assertResponseStatusCodeSame(404);
}
public function testGenerateLabelPdfWithInvalidElementIds(): void
{
$client = self::createAuthenticatedClient();
$client->request('POST', '/api/labels/generate', [
'json' => [
'profileId' => 1,
'elementIds' => 'invalid',
],
]);
// Should return 400 or 422 (validation error)
$response = $client->getResponse();
$statusCode = $response->getStatusCode();
self::assertTrue(
$statusCode === 400 || $statusCode === 422,
"Expected status code 400 or 422, got {$statusCode}"
);
}
public function testGenerateLabelPdfWithNonExistentElements(): void
{
self::createAuthenticatedClient()->request('POST', '/api/labels/generate', [
'json' => [
'profileId' => 1,
'elementIds' => '99999',
],
]);
self::assertResponseStatusCodeSame(404);
}
public function testGenerateLabelPdfRequiresAuthentication(): void
{
// Create a non-authenticated client
self::createClient()->request('POST', '/api/labels/generate', [
'json' => [
'profileId' => 1,
'elementIds' => '1',
],
]);
self::assertResponseStatusCodeSame(401);
}
}

View File

@@ -25,7 +25,7 @@ namespace App\Tests\API\Endpoints;
use App\Tests\API\Endpoints\CrudEndpointTestCase;
class ManufacturersEndpointTest extends CrudEndpointTestCase
final class ManufacturersEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string

View File

@@ -23,7 +23,7 @@ declare(strict_types=1);
namespace App\Tests\API\Endpoints;
class MeasurementUnitsEndpointTest extends CrudEndpointTestCase
final class MeasurementUnitsEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string

View File

@@ -25,7 +25,7 @@ namespace App\Tests\API\Endpoints;
use App\Tests\API\Endpoints\CrudEndpointTestCase;
class OrderdetailsEndpointTest extends CrudEndpointTestCase
final class OrderdetailsEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string

View File

@@ -23,7 +23,7 @@ declare(strict_types=1);
namespace App\Tests\API\Endpoints;
class ParametersEndpointTest extends CrudEndpointTestCase
final class ParametersEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string

View File

@@ -25,7 +25,7 @@ namespace App\Tests\API\Endpoints;
use App\Tests\API\Endpoints\CrudEndpointTestCase;
class PartAssociationsEndpointTest extends CrudEndpointTestCase
final class PartAssociationsEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string

View File

@@ -23,7 +23,7 @@ declare(strict_types=1);
namespace App\Tests\API\Endpoints;
class PartCustomStateEndpointTest extends CrudEndpointTestCase
final class PartCustomStateEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string

View File

@@ -23,7 +23,7 @@ declare(strict_types=1);
namespace App\Tests\API\Endpoints;
class PartEndpointTest extends CrudEndpointTestCase
final class PartEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string

View File

@@ -25,7 +25,7 @@ namespace App\Tests\API\Endpoints;
use App\Tests\API\Endpoints\CrudEndpointTestCase;
class PartLotsEndpointTest extends CrudEndpointTestCase
final class PartLotsEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string

View File

@@ -25,7 +25,7 @@ namespace App\Tests\API\Endpoints;
use App\Tests\API\Endpoints\CrudEndpointTestCase;
class PricedetailsEndpointTest extends CrudEndpointTestCase
final class PricedetailsEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string

View File

@@ -23,7 +23,7 @@ declare(strict_types=1);
namespace App\Tests\API\Endpoints;
class ProjectBOMEntriesEndpointTest extends CrudEndpointTestCase
final class ProjectBOMEntriesEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string

View File

@@ -25,7 +25,7 @@ namespace App\Tests\API\Endpoints;
use App\Tests\API\Endpoints\CrudEndpointTestCase;
class ProjectsEndpointTest extends CrudEndpointTestCase
final class ProjectsEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string

View File

@@ -25,7 +25,7 @@ namespace API\Endpoints;
use App\Tests\API\Endpoints\CrudEndpointTestCase;
class StorageLocationsEndpointTest extends CrudEndpointTestCase
final class StorageLocationsEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string

View File

@@ -25,7 +25,7 @@ namespace App\Tests\API\Endpoints;
use App\Tests\API\Endpoints\CrudEndpointTestCase;
class SuppliersEndpointTest extends CrudEndpointTestCase
final class SuppliersEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string

View File

@@ -23,7 +23,7 @@ declare(strict_types=1);
namespace App\Tests\API\Endpoints;
class UsersEndpointTest extends CrudEndpointTestCase
final class UsersEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string

View File

@@ -32,7 +32,7 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
*/
#[Group('DB')]
#[Group('slow')]
class ApplicationAvailabilityFunctionalTest extends WebTestCase
final class ApplicationAvailabilityFunctionalTest extends WebTestCase
{
#[DataProvider('urlProvider')]
public function testPageIsSuccessful(string $url): void

View File

@@ -27,7 +27,7 @@ use App\Entity\Attachments\AttachmentType;
#[Group('slow')]
#[Group('DB')]
class AttachmentTypeController extends AbstractAdminController
final class AttachmentTypeController extends AbstractAdminController
{
protected static string $base_path = '/en/attachment_type';
protected static string $entity_class = AttachmentType::class;

View File

@@ -27,7 +27,7 @@ use App\Entity\Parts\Category;
#[Group('slow')]
#[Group('DB')]
class CategoryController extends AbstractAdminController
final class CategoryController extends AbstractAdminController
{
protected static string $base_path = '/en/category';
protected static string $entity_class = Category::class;

View File

@@ -28,7 +28,7 @@ use App\Entity\Parts\Manufacturer;
#[Group('slow')]
#[Group('DB')]
class CurrencyController extends AbstractAdminController
final class CurrencyController extends AbstractAdminController
{
protected static string $base_path = '/en/currency';
protected static string $entity_class = Currency::class;

View File

@@ -27,7 +27,7 @@ use App\Entity\Parts\Footprint;
#[Group('slow')]
#[Group('DB')]
class FootprintController extends AbstractAdminController
final class FootprintController extends AbstractAdminController
{
protected static string $base_path = '/en/footprint';
protected static string $entity_class = Footprint::class;

View File

@@ -46,7 +46,7 @@ use PHPUnit\Framework\Attributes\Group;
use App\Entity\LabelSystem\LabelProfile;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
class LabelProfileController extends AbstractAdminController
final class LabelProfileController extends AbstractAdminController
{
protected static string $base_path = '/en/label_profile';
protected static string $entity_class = LabelProfile::class;

View File

@@ -27,7 +27,7 @@ use App\Entity\Parts\Manufacturer;
#[Group('slow')]
#[Group('DB')]
class ManufacturerController extends AbstractAdminController
final class ManufacturerController extends AbstractAdminController
{
protected static string $base_path = '/en/manufacturer';
protected static string $entity_class = Manufacturer::class;

View File

@@ -27,7 +27,7 @@ use App\Entity\Parts\MeasurementUnit;
#[Group('slow')]
#[Group('DB')]
class MeasurementUnitController extends AbstractAdminController
final class MeasurementUnitController extends AbstractAdminController
{
protected static string $base_path = '/en/measurement_unit';
protected static string $entity_class = MeasurementUnit::class;

View File

@@ -27,7 +27,7 @@ use PHPUnit\Framework\Attributes\Group;
#[Group('slow')]
#[Group('DB')]
class PartCustomStateControllerTest extends AbstractAdminController
final class PartCustomStateControllerTest extends AbstractAdminController
{
protected static string $base_path = '/en/part_custom_state';
protected static string $entity_class = PartCustomState::class;

View File

@@ -28,7 +28,7 @@ use App\Entity\ProjectSystem\Project;
#[Group('slow')]
#[Group('DB')]
class ProjectController extends AbstractAdminController
final class ProjectController extends AbstractAdminController
{
protected static string $base_path = '/en/project';
protected static string $entity_class = Project::class;

View File

@@ -27,7 +27,7 @@ use App\Entity\Parts\StorageLocation;
#[Group('slow')]
#[Group('DB')]
class StorelocationController extends AbstractAdminController
final class StorelocationController extends AbstractAdminController
{
protected static string $base_path = '/en/store_location';
protected static string $entity_class = StorageLocation::class;

View File

@@ -27,7 +27,7 @@ use App\Entity\Parts\Supplier;
#[Group('slow')]
#[Group('DB')]
class SupplierController extends AbstractAdminController
final class SupplierController extends AbstractAdminController
{
protected static string $base_path = '/en/supplier';
protected static string $entity_class = Supplier::class;

View File

@@ -22,6 +22,8 @@ declare(strict_types=1);
namespace App\Tests\Controller;
use App\Services\InfoProviderSystem\BulkInfoProviderService;
use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
use App\Entity\InfoProviderSystem\BulkImportJobStatus;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
use App\Entity\Parts\Part;
@@ -36,7 +38,7 @@ use Symfony\Component\HttpFoundation\Response;
#[Group("slow")]
#[Group("DB")]
class BulkInfoProviderImportControllerTest extends WebTestCase
final class BulkInfoProviderImportControllerTest extends WebTestCase
{
public function testStep1WithoutIds(): void
{
@@ -174,8 +176,8 @@ class BulkInfoProviderImportControllerTest extends WebTestCase
// Verify the template rendered the source_field and source_keyword correctly
$content = $client->getResponse()->getContent();
$this->assertStringContainsString('test_field', $content);
$this->assertStringContainsString('test_keyword', $content);
$this->assertStringContainsString('test_field', (string) $content);
$this->assertStringContainsString('test_keyword', (string) $content);
// Clean up - find by ID to avoid detached entity issues
$jobId = $job->getId();
@@ -607,7 +609,7 @@ class BulkInfoProviderImportControllerTest extends WebTestCase
}
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$this->assertStringContainsString('Bulk Info Provider Import', $client->getResponse()->getContent());
$this->assertStringContainsString('Bulk Info Provider Import', (string) $client->getResponse()->getContent());
}
public function testStep1FormSubmissionWithErrors(): void
@@ -630,7 +632,7 @@ class BulkInfoProviderImportControllerTest extends WebTestCase
}
$this->assertResponseStatusCodeSame(Response::HTTP_OK);
$this->assertStringContainsString('Bulk Info Provider Import', $client->getResponse()->getContent());
$this->assertStringContainsString('Bulk Info Provider Import', (string) $client->getResponse()->getContent());
}
public function testBulkInfoProviderServiceKeywordExtraction(): void
@@ -647,18 +649,18 @@ class BulkInfoProviderImportControllerTest extends WebTestCase
}
// Test that the service can extract keywords from parts
$bulkService = $client->getContainer()->get(\App\Services\InfoProviderSystem\BulkInfoProviderService::class);
$bulkService = $client->getContainer()->get(BulkInfoProviderService::class);
// Create field mappings to verify the service works
$fieldMappings = [
new \App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO('name', ['test'], 1),
new \App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO('mpn', ['test'], 2)
new BulkSearchFieldMappingDTO('name', ['test'], 1),
new BulkSearchFieldMappingDTO('mpn', ['test'], 2)
];
// The service may return an empty result or throw when no results are found
try {
$result = $bulkService->performBulkSearch([$part], $fieldMappings, false);
$this->assertInstanceOf(\App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO::class, $result);
$this->assertInstanceOf(BulkSearchResponseDTO::class, $result);
} catch (\RuntimeException $e) {
$this->assertStringContainsString('No search results found', $e->getMessage());
}
@@ -725,12 +727,12 @@ class BulkInfoProviderImportControllerTest extends WebTestCase
}
// Test that the service can handle supplier part number fields
$bulkService = $client->getContainer()->get(\App\Services\InfoProviderSystem\BulkInfoProviderService::class);
$bulkService = $client->getContainer()->get(BulkInfoProviderService::class);
// Create field mappings with supplier SPN field mapping
$fieldMappings = [
new \App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO('invalid_field', ['test'], 1),
new \App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO('test_supplier_spn', ['test'], 2)
new BulkSearchFieldMappingDTO('invalid_field', ['test'], 1),
new BulkSearchFieldMappingDTO('test_supplier_spn', ['test'], 2)
];
// The service should be able to process the request and throw an exception when no results are found
@@ -756,11 +758,11 @@ class BulkInfoProviderImportControllerTest extends WebTestCase
}
// Test that the service can handle batch processing
$bulkService = $client->getContainer()->get(\App\Services\InfoProviderSystem\BulkInfoProviderService::class);
$bulkService = $client->getContainer()->get(BulkInfoProviderService::class);
// Create field mappings with multiple keywords
$fieldMappings = [
new \App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO('empty', ['test'], 1)
new BulkSearchFieldMappingDTO('empty', ['test'], 1)
];
// The service should be able to process the request and throw an exception when no results are found
@@ -786,7 +788,7 @@ class BulkInfoProviderImportControllerTest extends WebTestCase
}
// Test that the service can handle prefetch details
$bulkService = $client->getContainer()->get(\App\Services\InfoProviderSystem\BulkInfoProviderService::class);
$bulkService = $client->getContainer()->get(BulkInfoProviderService::class);
// Create empty search results to test prefetch method
$searchResults = new BulkSearchResponseDTO([

View File

@@ -27,7 +27,7 @@ use App\DataFixtures\APITokenFixtures;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class KiCadApiControllerTest extends WebTestCase
final class KiCadApiControllerTest extends WebTestCase
{
private const BASE_URL = '/en/kicad-api/v1';

View File

@@ -38,7 +38,7 @@ use Symfony\Component\HttpFoundation\Response;
#[Group("slow")]
#[Group("DB")]
class PartControllerTest extends WebTestCase
final class PartControllerTest extends WebTestCase
{
public function testShowPart(): void
{

View File

@@ -33,7 +33,7 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
#[Group('slow')]
#[Group('DB')]
class RedirectControllerTest extends WebTestCase
final class RedirectControllerTest extends WebTestCase
{
protected EntityManagerInterface $em;
protected UserRepository $userRepo;

View File

@@ -25,7 +25,7 @@ namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ScanControllerTest extends WebTestCase
final class ScanControllerTest extends WebTestCase
{
private ?KernelBrowser $client = null;

View File

@@ -27,7 +27,7 @@ use App\DataTables\Filters\FilterInterface;
use Doctrine\ORM\QueryBuilder;
use PHPUnit\Framework\TestCase;
class CompoundFilterTraitTest extends TestCase
final class CompoundFilterTraitTest extends TestCase
{
public function testFindAllChildFiltersEmpty(): void
@@ -49,9 +49,9 @@ class CompoundFilterTraitTest extends TestCase
public function testFindAllChildFilters(): void
{
$f1 = $this->createMock(FilterInterface::class);
$f2 = $this->createMock(FilterInterface::class);
$f3 = $this->createMock(FilterInterface::class);
$f1 = $this->createStub(FilterInterface::class);
$f2 = $this->createStub(FilterInterface::class);
$f3 = $this->createStub(FilterInterface::class);
$filter = new class($f1, $f2, $f3, null) {
use CompoundFilterTrait;
@@ -108,7 +108,7 @@ class CompoundFilterTraitTest extends TestCase
}
};
$qb = $this->createMock(QueryBuilder::class);
$qb = $this->createStub(QueryBuilder::class);
$filter->_applyAllChildFilters($qb);
}

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