Compare commits

...

13 commits

Author SHA1 Message Date
Jan Böhmer
05a9e4d035 Merge remote-tracking branch 'origin/master'
Some checks are pending
Build assets artifact / Build assets artifact (push) Waiting to run
Docker Image Build / docker (push) Waiting to run
Docker Image Build (FrankenPHP) / docker (push) Waiting to run
Static analysis / Static analysis (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Waiting to run
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Waiting to run
2026-02-15 22:33:23 +01:00
Jan Böhmer
be808e28bc Updated dependencies 2026-02-15 22:29:16 +01:00
Jan Böhmer
7354b37ef6
New Crowdin updates (#1228)
* New translations messages.en.xlf (German)

* New translations messages.en.xlf (German)

* New translations validators.en.xlf (Polish)

* New translations security.en.xlf (Danish)

* New translations security.en.xlf (Ukrainian)

* New translations security.en.xlf (German)

* New translations security.en.xlf (Hungarian)

* New translations security.en.xlf (Dutch)

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

* New translations messages.en.xlf (English)

* New translations validators.en.xlf (English)

* New translations security.en.xlf (English)

* New translations frontend.en.xlf (Danish)

* New translations frontend.en.xlf (German)

* New translations frontend.en.xlf (Hungarian)

* New translations frontend.en.xlf (Ukrainian)

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

* New translations frontend.en.xlf (English)

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

* Improved API docs for label endpoint

* Improved LabelGenerationProcessor

---------

Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-02-15 16:03:07 +01:00
buchmann
47c0d78985 only autofocus if new 2026-02-11 14:26:36 +01:00
buchmann
76f0b05a09 Autofocus for frequently used input fields
Fixes #1157.
- Focus `name` field on new part
- Focus `amount` on add/withdraw modal
- Focus first "number type" input on any newly added collectionType table row... (debatable)

It would be even more favorable if the user could configure if they want to use autofocus and/or for which fields/dialogs it should be enabled.
2026-02-11 14:10:05 +01:00
32 changed files with 653 additions and 69 deletions

View file

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

View file

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

16
composer.lock generated
View file

@ -16558,16 +16558,16 @@
},
{
"name": "thecodingmachine/safe",
"version": "v3.3.0",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236"
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/2cdd579eeaa2e78e51c7509b50cc9fb89a956236",
"reference": "2cdd579eeaa2e78e51c7509b50cc9fb89a956236",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
@ -16677,7 +16677,7 @@
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.3.0"
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
@ -16688,12 +16688,16 @@
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2025-05-14T06:15:44+00:00"
"time": "2026-02-04T18:08:13+00:00"
},
{
"name": "tiendanube/gtinvalidation",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -65,28 +65,28 @@ class SandboxedLabelExtension extends AbstractExtension
*/
public function associatedParts(AbstractPartsContainingDBElement $element): array
{
/** @var AbstractPartsContainingRepository $repo */
/** @var AbstractPartsContainingRepository<AbstractPartsContainingDBElement> $repo */
$repo = $this->em->getRepository($element::class);
return $repo->getParts($element);
}
public function associatedPartsCount(AbstractPartsContainingDBElement $element): int
{
/** @var AbstractPartsContainingRepository $repo */
/** @var AbstractPartsContainingRepository<AbstractPartsContainingDBElement> $repo */
$repo = $this->em->getRepository($element::class);
return $repo->getPartsCount($element);
}
public function associatedPartsRecursive(AbstractPartsContainingDBElement $element): array
{
/** @var AbstractPartsContainingRepository $repo */
/** @var AbstractPartsContainingRepository<AbstractPartsContainingDBElement> $repo */
$repo = $this->em->getRepository($element::class);
return $repo->getPartsRecursive($element);
}
public function associatedPartsCountRecursive(AbstractPartsContainingDBElement $element): int
{
/** @var AbstractPartsContainingRepository $repo */
/** @var AbstractPartsContainingRepository<AbstractPartsContainingDBElement> $repo */
$repo = $this->em->getRepository($element::class);
return $repo->getPartsCountRecursive($element);
}

View file

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

View file

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

View file

@ -0,0 +1,186 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\API\Endpoints;
use App\Tests\API\AuthenticatedApiTestCase;
class LabelEndpointTest extends AuthenticatedApiTestCase
{
public function testGetLabelProfiles(): void
{
$response = self::createAuthenticatedClient()->request('GET', '/api/label_profiles');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
// Check that we get an array of label profiles
$json = $response->toArray();
self::assertIsArray($json['hydra:member']);
self::assertNotEmpty($json['hydra:member']);
// Check the structure of the first profile
$firstProfile = $json['hydra:member'][0];
self::assertArrayHasKey('@id', $firstProfile);
self::assertArrayHasKey('name', $firstProfile);
self::assertArrayHasKey('options', $firstProfile);
self::assertArrayHasKey('show_in_dropdown', $firstProfile);
}
public function testGetSingleLabelProfile(): void
{
$response = self::createAuthenticatedClient()->request('GET', '/api/label_profiles/1');
self::assertResponseIsSuccessful();
self::assertJsonContains([
'@id' => '/api/label_profiles/1',
]);
$json = $response->toArray();
self::assertArrayHasKey('name', $json);
self::assertArrayHasKey('options', $json);
// Note: options is serialized but individual fields like width/height
// are only available in 'extended' or 'full' serialization groups
self::assertIsArray($json['options']);
}
public function testFilterLabelProfilesByElementType(): void
{
$response = self::createAuthenticatedClient()->request('GET', '/api/label_profiles?options.supported_element=part');
self::assertResponseIsSuccessful();
$json = $response->toArray();
// Check that we get results - the filter should work even if the field isn't in response
self::assertIsArray($json['hydra:member']);
// verify we got profiles
self::assertNotEmpty($json['hydra:member']);
}
public function testGenerateLabelPdf(): void
{
$response = self::createAuthenticatedClient()->request('POST', '/api/labels/generate', [
'json' => [
'profileId' => 1,
'elementIds' => '1',
],
]);
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('content-type', 'application/pdf');
// Check that the response contains PDF data
$content = $response->getContent();
self::assertStringStartsWith('%PDF-', $content);
// Check Content-Disposition header contains attachment and .pdf
$headers = $response->getHeaders();
self::assertArrayHasKey('content-disposition', $headers);
$disposition = $headers['content-disposition'][0];
self::assertStringContainsString('attachment', $disposition);
self::assertStringContainsString('.pdf', $disposition);
}
public function testGenerateLabelPdfWithMultipleElements(): void
{
$response = self::createAuthenticatedClient()->request('POST', '/api/labels/generate', [
'json' => [
'profileId' => 1,
'elementIds' => '1,2,3',
],
]);
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('content-type', 'application/pdf');
self::assertStringStartsWith('%PDF-', $response->getContent());
}
public function testGenerateLabelPdfWithRange(): void
{
$response = self::createAuthenticatedClient()->request('POST', '/api/labels/generate', [
'json' => [
'profileId' => 1,
'elementIds' => '1-3',
],
]);
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('content-type', 'application/pdf');
self::assertStringStartsWith('%PDF-', $response->getContent());
}
public function testGenerateLabelPdfWithInvalidProfileId(): void
{
self::createAuthenticatedClient()->request('POST', '/api/labels/generate', [
'json' => [
'profileId' => 99999,
'elementIds' => '1',
],
]);
self::assertResponseStatusCodeSame(404);
}
public function testGenerateLabelPdfWithInvalidElementIds(): void
{
$client = self::createAuthenticatedClient();
$client->request('POST', '/api/labels/generate', [
'json' => [
'profileId' => 1,
'elementIds' => 'invalid',
],
]);
// Should return 400 or 422 (validation error)
$response = $client->getResponse();
$statusCode = $response->getStatusCode();
self::assertTrue(
$statusCode === 400 || $statusCode === 422,
"Expected status code 400 or 422, got {$statusCode}"
);
}
public function testGenerateLabelPdfWithNonExistentElements(): void
{
self::createAuthenticatedClient()->request('POST', '/api/labels/generate', [
'json' => [
'profileId' => 1,
'elementIds' => '99999',
],
]);
self::assertResponseStatusCodeSame(404);
}
public function testGenerateLabelPdfRequiresAuthentication(): void
{
// Create a non-authenticated client
self::createClient()->request('POST', '/api/labels/generate', [
'json' => [
'profileId' => 1,
'elementIds' => '1',
],
]);
self::assertResponseStatusCodeSame(401);
}
}

View file

@ -56,4 +56,4 @@
</segment>
</unit>
</file>
</xliff>
</xliff>

View file

@ -56,4 +56,4 @@
</segment>
</unit>
</file>
</xliff>
</xliff>

View file

@ -56,4 +56,4 @@
</segment>
</unit>
</file>
</xliff>
</xliff>

View file

@ -56,4 +56,4 @@
</segment>
</unit>
</file>
</xliff>
</xliff>

View file

@ -56,4 +56,4 @@
</segment>
</unit>
</file>
</xliff>
</xliff>

View file

@ -56,4 +56,4 @@
</segment>
</unit>
</file>
</xliff>
</xliff>

View file

@ -11868,7 +11868,7 @@ Buerklin-API-Authentication-Server:
<unit id="Ps4N7pW" name="update_manager.view_release">
<segment state="translated">
<source>update_manager.view_release</source>
<target>update_manager.view_release</target>
<target>Release ansehen</target>
</segment>
</unit>
<unit id="Op0GjdW" name="update_manager.could_not_fetch_releases">
@ -11964,7 +11964,7 @@ Buerklin-API-Authentication-Server:
<unit id="DYpFv6Y" name="update_manager.view_release_notes">
<segment state="translated">
<source>update_manager.view_release_notes</source>
<target>update_manager.view_release_notes</target>
<target>Release notes ansehen</target>
</segment>
</unit>
<unit id="8OQbJJF" name="update_manager.update_logs">
@ -12102,7 +12102,7 @@ Buerklin-API-Authentication-Server:
<unit id="Gt.91s_" name="perm.system.manage_updates">
<segment state="translated">
<source>perm.system.manage_updates</source>
<target>perm.system.manage_updates</target>
<target>Part-DB Updated verwalten</target>
</segment>
</unit>
<unit id="Mw2sya4" name="update_manager.create_backup">
@ -12354,13 +12354,13 @@ Buerklin-API-Authentication-Server:
<unit id="Pu8juaH" name="settings.ips.generic_web_provider.enabled.help">
<segment state="translated">
<source>settings.ips.generic_web_provider.enabled.help</source>
<target>settings.ips.generic_web_provider.enabled.help</target>
<target>Wenn der Anbieter aktiviert ist, können Benutzer im Namen des Part-DB-Servers Anfragen an beliebige Websites stellen. Aktivieren Sie diese Option nur, wenn Sie sich der möglichen Folgen bewusst sind.</target>
</segment>
</unit>
<unit id="IvIOYcn" name="info_providers.from_url.title">
<segment state="translated">
<source>info_providers.from_url.title</source>
<target>Erstelle [part] aus URL</target>
<target>Erstelle [Part] aus URL</target>
</segment>
</unit>
<unit id="QLL7vDC" name="info_providers.from_url.url.label">
@ -12399,5 +12399,113 @@ Buerklin-API-Authentication-Server:
<target>Update zu</target>
</segment>
</unit>
<unit id="XPhnMxn" name="part.gtin">
<segment state="translated">
<source>part.gtin</source>
<target>GTIN / EAN</target>
</segment>
</unit>
<unit id="TyykD7B" name="info_providers.capabilities.gtin">
<segment state="translated">
<source>info_providers.capabilities.gtin</source>
<target>GTIN / EAN</target>
</segment>
</unit>
<unit id="JBGly8p" name="part.table.gtin">
<segment state="translated">
<source>part.table.gtin</source>
<target>GTIN</target>
</segment>
</unit>
<unit id="0qHQof." name="scan_dialog.mode.gtin">
<segment state="translated">
<source>scan_dialog.mode.gtin</source>
<target>GTIN / EAN Barcode</target>
</segment>
</unit>
<unit id="cmchX59" name="attachment_type.edit.allowed_targets">
<segment state="translated">
<source>attachment_type.edit.allowed_targets</source>
<target>Nur verwenden für</target>
</segment>
</unit>
<unit id="t5R8p1l" name="attachment_type.edit.allowed_targets.help">
<segment state="translated">
<source>attachment_type.edit.allowed_targets.help</source>
<target>Machen Sie diesen Anhangstyp nur für bestimmte Elementtypen verfügbar. Leer lassen, um diesen Anhangstyp für alle Elementtypen anzuzeigen.</target>
</segment>
</unit>
<unit id="LvlEUjC" name="orderdetails.edit.prices_includes_vat">
<segment state="translated">
<source>orderdetails.edit.prices_includes_vat</source>
<target>Preise einschl. MwSt.</target>
</segment>
</unit>
<unit id="GUsVh5T" name="prices.incl_vat">
<segment state="translated">
<source>prices.incl_vat</source>
<target>Inkl. MwSt.</target>
</segment>
</unit>
<unit id="3ipwaVQ" name="prices.excl_vat">
<segment state="translated">
<source>prices.excl_vat</source>
<target>Exkl. MwSt.</target>
</segment>
</unit>
<unit id="WDJ7EeF" name="settings.system.localization.prices_include_tax_by_default">
<segment state="translated">
<source>settings.system.localization.prices_include_tax_by_default</source>
<target>Preise enthalten standardmäßig Mehrwertsteuer</target>
</segment>
</unit>
<unit id="01oGY_r" name="settings.system.localization.prices_include_tax_by_default.description">
<segment state="translated">
<source>settings.system.localization.prices_include_tax_by_default.description</source>
<target>Der Standardwert für neu erstellte Einkaufinformationen, ob die Preise Mehrwertsteuer enthalten oder nicht.</target>
</segment>
</unit>
<unit id="heWSnAH" name="part_lot.edit.last_stocktake_at">
<segment state="translated">
<source>part_lot.edit.last_stocktake_at</source>
<target>Letzte Inventur</target>
</segment>
</unit>
<unit id=".LP93kG" name="perm.parts_stock.stocktake">
<segment state="translated">
<source>perm.parts_stock.stocktake</source>
<target>Inventur</target>
</segment>
</unit>
<unit id="Vnhrb5R" name="part.info.stocktake_modal.title">
<segment state="translated">
<source>part.info.stocktake_modal.title</source>
<target>Inventur des Bestandes</target>
</segment>
</unit>
<unit id="WqOG7RK" name="part.info.stocktake_modal.expected_amount">
<segment state="translated">
<source>part.info.stocktake_modal.expected_amount</source>
<target>Erwartete Menge</target>
</segment>
</unit>
<unit id="E7IbVN6" name="part.info.stocktake_modal.actual_amount">
<segment state="translated">
<source>part.info.stocktake_modal.actual_amount</source>
<target>Tatsächliche Menge</target>
</segment>
</unit>
<unit id="4GwSma7" name="log.part_stock_changed.stock_take">
<segment state="translated">
<source>log.part_stock_changed.stock_take</source>
<target>Inventur</target>
</segment>
</unit>
<unit id="aRQPMW7" name="log.element_edited.changed_fields.last_stocktake_at">
<segment state="translated">
<source>log.element_edited.changed_fields.last_stocktake_at</source>
<target>Letzte Inventur</target>
</segment>
</unit>
</file>
</xliff>
</xliff>

View file

@ -12402,109 +12402,109 @@ Buerklin-API Authentication server:
</segment>
</unit>
<unit id="XPhnMxn" name="part.gtin">
<segment>
<segment state="translated">
<source>part.gtin</source>
<target>GTIN / EAN</target>
</segment>
</unit>
<unit id="TyykD7B" name="info_providers.capabilities.gtin">
<segment>
<segment state="translated">
<source>info_providers.capabilities.gtin</source>
<target>GTIN / EAN</target>
</segment>
</unit>
<unit id="JBGly8p" name="part.table.gtin">
<segment>
<segment state="translated">
<source>part.table.gtin</source>
<target>GTIN</target>
</segment>
</unit>
<unit id="0qHQof." name="scan_dialog.mode.gtin">
<segment>
<segment state="translated">
<source>scan_dialog.mode.gtin</source>
<target>GTIN / EAN barcode</target>
</segment>
</unit>
<unit id="cmchX59" name="attachment_type.edit.allowed_targets">
<segment>
<segment state="translated">
<source>attachment_type.edit.allowed_targets</source>
<target>Use only for</target>
</segment>
</unit>
<unit id="t5R8p1l" name="attachment_type.edit.allowed_targets.help">
<segment>
<segment state="translated">
<source>attachment_type.edit.allowed_targets.help</source>
<target>Make this attachment type only available for certain element classes. Leave empty to show this attachment type for all element classes.</target>
</segment>
</unit>
<unit id="LvlEUjC" name="orderdetails.edit.prices_includes_vat">
<segment>
<segment state="translated">
<source>orderdetails.edit.prices_includes_vat</source>
<target>Prices include VAT</target>
</segment>
</unit>
<unit id="GUsVh5T" name="prices.incl_vat">
<segment>
<segment state="translated">
<source>prices.incl_vat</source>
<target>Incl. VAT</target>
</segment>
</unit>
<unit id="3ipwaVQ" name="prices.excl_vat">
<segment>
<segment state="translated">
<source>prices.excl_vat</source>
<target>Excl. VAT</target>
</segment>
</unit>
<unit id="WDJ7EeF" name="settings.system.localization.prices_include_tax_by_default">
<segment>
<segment state="translated">
<source>settings.system.localization.prices_include_tax_by_default</source>
<target>Prices include VAT by default</target>
</segment>
</unit>
<unit id="01oGY_r" name="settings.system.localization.prices_include_tax_by_default.description">
<segment>
<segment state="translated">
<source>settings.system.localization.prices_include_tax_by_default.description</source>
<target>The default value for newly created purchase infos, if prices include VAT or not.</target>
</segment>
</unit>
<unit id="heWSnAH" name="part_lot.edit.last_stocktake_at">
<segment>
<segment state="translated">
<source>part_lot.edit.last_stocktake_at</source>
<target>Last stocktake</target>
</segment>
</unit>
<unit id=".LP93kG" name="perm.parts_stock.stocktake">
<segment>
<segment state="translated">
<source>perm.parts_stock.stocktake</source>
<target>Stocktake</target>
</segment>
</unit>
<unit id="Vnhrb5R" name="part.info.stocktake_modal.title">
<segment>
<segment state="translated">
<source>part.info.stocktake_modal.title</source>
<target>Stocktake lot</target>
</segment>
</unit>
<unit id="WqOG7RK" name="part.info.stocktake_modal.expected_amount">
<segment>
<segment state="translated">
<source>part.info.stocktake_modal.expected_amount</source>
<target>Expected amount</target>
</segment>
</unit>
<unit id="E7IbVN6" name="part.info.stocktake_modal.actual_amount">
<segment>
<segment state="translated">
<source>part.info.stocktake_modal.actual_amount</source>
<target>Actual amount</target>
</segment>
</unit>
<unit id="4GwSma7" name="log.part_stock_changed.stock_take">
<segment>
<segment state="translated">
<source>log.part_stock_changed.stock_take</source>
<target>Stocktake</target>
</segment>
</unit>
<unit id="aRQPMW7" name="log.element_edited.changed_fields.last_stocktake_at">
<segment>
<segment state="translated">
<source>log.element_edited.changed_fields.last_stocktake_at</source>
<target>Last stocktake</target>
</segment>

View file

@ -20,4 +20,4 @@
</segment>
</unit>
</file>
</xliff>
</xliff>

View file

@ -20,4 +20,4 @@
</segment>
</unit>
</file>
</xliff>
</xliff>

View file

@ -20,4 +20,4 @@
</segment>
</unit>
</file>
</xliff>
</xliff>

View file

@ -20,4 +20,4 @@
</segment>
</unit>
</file>
</xliff>
</xliff>

View file

@ -20,4 +20,4 @@
</segment>
</unit>
</file>
</xliff>
</xliff>

View file

@ -20,4 +20,4 @@
</segment>
</unit>
</file>
</xliff>
</xliff>

View file

@ -20,4 +20,4 @@
</segment>
</unit>
</file>
</xliff>
</xliff>

View file

@ -248,7 +248,7 @@
</segment>
</unit>
<unit id="zT_j_oQ" name="validator.invalid_gtin">
<segment>
<segment state="translated">
<source>validator.invalid_gtin</source>
<target>This is not an valid GTIN / EAN!</target>
</segment>

View file

@ -148,7 +148,7 @@
<unit id="asBxPxe" name="project.bom_has_to_include_all_subelement_parts">
<segment state="translated">
<source>project.bom_has_to_include_all_subelement_parts</source>
<target>BOM projektu musi zawierać wszystkie komponenty produkcyjne podprojektów. Brakuje komponentu %part_name% projektu %project_name%!</target>
<target>BOM projektu musi zawierać wszystkie komponenty produkcyjne pod projektów</target>
</segment>
</unit>
<unit id="uxaE9Ct" name="project.bom_entry.price_not_allowed_on_parts">
@ -223,6 +223,12 @@
<target>Ze względu na ograniczenia techniczne nie jest możliwe wybranie daty po 19 stycznia 2038 w systemach 32-bitowych!</target>
</segment>
</unit>
<unit id="iM9yb_p" name="validator.fileSize.invalidFormat">
<segment state="translated">
<source>validator.fileSize.invalidFormat</source>
<target>Niewłaściwy format</target>
</segment>
</unit>
<unit id="ZFxQ0BZ" name="validator.invalid_range">
<segment state="translated">
<source>validator.invalid_range</source>
@ -235,5 +241,11 @@
<target>Nieprawidłowy kod. Sprawdź, czy aplikacja uwierzytelniająca jest poprawnie skonfigurowana i czy zarówno serwer, jak i urządzenie uwierzytelniające mają poprawnie ustawiony czas.</target>
</segment>
</unit>
<unit id="I330cr5" name="settings.synonyms.type_synonyms.collection_type.duplicate">
<segment state="translated">
<source>settings.synonyms.type_synonyms.collection_type.duplicate</source>
<target>Duplikuj</target>
</segment>
</unit>
</file>
</xliff>
</xliff>

View file

@ -2800,9 +2800,9 @@ caniuse-api@^3.0.0:
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001759:
version "1.0.30001769"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz#1ad91594fad7dc233777c2781879ab5409f7d9c2"
integrity sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==
version "1.0.30001770"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz#4dc47d3b263a50fbb243448034921e0a88591a84"
integrity sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==
ccount@^2.0.0:
version "2.0.1"
@ -7855,9 +7855,9 @@ webpack-sources@^2.0.1, webpack-sources@^2.2.0:
source-map "^0.6.1"
webpack-sources@^3.3.3:
version "3.3.3"
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.3.tgz#d4bf7f9909675d7a070ff14d0ef2a4f3c982c723"
integrity sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==
version "3.3.4"
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.4.tgz#a338b95eb484ecc75fbb196cbe8a2890618b4891"
integrity sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==
webpack@^5.74.0:
version "5.105.2"