mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-02-23 02:02:29 +01:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70cde4c3a8 | ||
|
|
28e6ca52fe | ||
|
|
5b4c1505b7 | ||
|
|
8ad3c2e612 | ||
|
|
d7ed2225b4 | ||
|
|
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.
|
||||
|
||||
@@ -108,11 +108,19 @@ export default class extends Controller {
|
||||
const raw_order = saved_state.order;
|
||||
|
||||
settings.initial_order = raw_order.map((order) => {
|
||||
//Skip if direction is empty, as this is the default, otherwise datatables server is confused when the order is sent in the request, but the initial order is set to an empty direction
|
||||
if (order[1] === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
column: order[0],
|
||||
dir: order[1]
|
||||
}
|
||||
});
|
||||
|
||||
//Remove null values from the initial_order array
|
||||
settings.initial_order = settings.initial_order.filter(order => order !== null);
|
||||
}
|
||||
|
||||
let options = {
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -117,7 +117,6 @@ class PartLotType extends AbstractType
|
||||
'widget' => 'single_text',
|
||||
'disabled' => !$this->security->isGranted('@parts_stock.stocktake'),
|
||||
'required' => false,
|
||||
'empty_data' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -41,35 +41,37 @@
|
||||
{{ lot.amount | format_amount(part.partUnit, {'decimals': 5}) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="d-flex flex-column align-items-start">
|
||||
{% if lot.owner %}
|
||||
<span class="badge text-bg-light mb-1" title="{% trans %}part_lot.owner{% endtrans %}">
|
||||
<td >
|
||||
<div class="d-flex flex-column align-items-start">
|
||||
{% if lot.owner %}
|
||||
<span class="badge text-bg-light mb-1" title="{% trans %}part_lot.owner{% endtrans %}">
|
||||
{{ helper.user_icon_link(lot.owner) }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if lot.expirationDate %}
|
||||
<span class="badge text-bg-info mb-1" title="{% trans %}part_lots.expiration_date{% endtrans %}">
|
||||
{% endif %}
|
||||
{% if lot.expirationDate %}
|
||||
<span class="badge text-bg-info mb-1" title="{% trans %}part_lots.expiration_date{% endtrans %}">
|
||||
<i class="fas fa-calendar-alt fa-fw"></i> {{ lot.expirationDate | format_date() }}<br>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if lot.expired %}
|
||||
<span class="badge text-bg-warning mb-1">
|
||||
{% endif %}
|
||||
{% if lot.expired %}
|
||||
<span class="badge text-bg-warning mb-1">
|
||||
<i class="fas fa-exclamation-circle fa-fw"></i>
|
||||
{% trans %}part_lots.is_expired{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if lot.needsRefill %}
|
||||
<span class="badge text-bg-warning mb-1">
|
||||
{% endif %}
|
||||
{% if lot.needsRefill %}
|
||||
<span class="badge text-bg-warning mb-1">
|
||||
<i class="fas fa-dolly fa-fw"></i>
|
||||
{% trans %}part_lots.need_refill{% endtrans %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if lot.lastStocktakeAt %}
|
||||
<span class="badge text-bg-secondary" title="{% trans %}part_lot.edit.last_stocktake_at{% endtrans %}">
|
||||
{% endif %}
|
||||
{% if lot.lastStocktakeAt %}
|
||||
<span class="badge text-bg-secondary" title="{% trans %}part_lot.edit.last_stocktake_at{% endtrans %}">
|
||||
<i class="fas fa-clipboard-check fa-fw"></i>
|
||||
{{ lot.lastStocktakeAt | format_datetime("short") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user