mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-02-21 09:12:29 +01:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d6b84af3d | ||
|
|
80492a7b68 | ||
|
|
7069af4054 | ||
|
|
05a9e4d035 | ||
|
|
be808e28bc | ||
|
|
7354b37ef6 | ||
|
|
6afca44897 | ||
|
|
c17cf2baa1 | ||
|
|
c00556829a | ||
|
|
f024c4b09e | ||
|
|
8e0fcdb73b | ||
|
|
e19929249f | ||
|
|
f6764170e1 | ||
|
|
1641708508 | ||
|
|
97a74815d3 | ||
|
|
7998cdcd71 | ||
|
|
5e9f7a11a3 | ||
|
|
1c6bf3f472 | ||
|
|
aed2652f1d | ||
|
|
233c5e8550 | ||
|
|
6b83c772cc | ||
|
|
1996db6a53 | ||
|
|
f69b0889eb | ||
|
|
c8b1320bb9 | ||
|
|
e11cb7d5cb | ||
|
|
097041a43a | ||
|
|
b21d294cf8 | ||
|
|
43d72faf48 | ||
|
|
bc9a93d71f | ||
|
|
df0ac76394 | ||
|
|
66040b687f | ||
|
|
7a83581597 | ||
|
|
47c0d78985 | ||
|
|
76f0b05a09 | ||
|
|
41252d8bb9 |
@@ -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
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
worker {
|
||||
file ./public/index.php
|
||||
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
|
||||
}
|
||||
|
||||
113
.github/workflows/docker_build.yml
vendored
113
.github/workflows/docker_build.yml
vendored
@@ -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 }}
|
||||
|
||||
113
.github/workflows/docker_frankenphp.yml
vendored
113
.github/workflows/docker_frankenphp.yml
vendored
@@ -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 }}
|
||||
|
||||
115
Dockerfile
115
Dockerfile
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
585
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
;
|
||||
|
||||
84
src/ApiResource/LabelGenerationRequest.php
Normal file
84
src/ApiResource/LabelGenerationRequest.php
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventSubscriber\UserSystem;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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']),
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -92,9 +92,7 @@ class UserSettingsType extends AbstractType
|
||||
'accept' => 'image/*',
|
||||
],
|
||||
'constraints' => [
|
||||
new File([
|
||||
'maxSize' => '5M',
|
||||
]),
|
||||
new File(maxSize: '5M'),
|
||||
],
|
||||
])
|
||||
->add('aboutMe', RichTextEditorType::class, [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? '';
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ final class LabelHTMLGenerator
|
||||
'paper_height' => $options->getHeight(),
|
||||
]
|
||||
);
|
||||
} catch (Error $exception) {
|
||||
} catch (\Throwable $exception) {
|
||||
throw new TwigModeException($exception);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'])) {
|
||||
|
||||
141
src/State/LabelGenerationProcessor.php
Normal file
141
src/State/LabelGenerationProcessor.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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 ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
57
src/Twig/UserRepoExtension.php
Normal file
57
src/Twig/UserRepoExtension.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use Attribute;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
|
||||
@@ -155,3 +155,8 @@
|
||||
{{- parent() -}}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block boolean_constraint_widget %}
|
||||
{{ form_widget(form.value) }}
|
||||
{{ form_errors(form.value) }}
|
||||
{% endblock %}
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
186
tests/API/Endpoints/LabelEndpointTest.php
Normal file
186
tests/API/Endpoints/LabelEndpointTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user