diff --git a/src/ApiResource/LabelGenerationRequest.php b/src/ApiResource/LabelGenerationRequest.php new file mode 100644 index 00000000..9b4462a0 --- /dev/null +++ b/src/ApiResource/LabelGenerationRequest.php @@ -0,0 +1,84 @@ +. + */ + +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; +} diff --git a/src/Entity/LabelSystem/LabelProfile.php b/src/Entity/LabelSystem/LabelProfile.php index d3616c34..236c07f7 100644 --- a/src/Entity/LabelSystem/LabelProfile.php +++ b/src/Entity/LabelSystem/LabelProfile.php @@ -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 */ +#[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() diff --git a/src/State/LabelGenerationProcessor.php b/src/State/LabelGenerationProcessor.php new file mode 100644 index 00000000..0472bbbd --- /dev/null +++ b/src/State/LabelGenerationProcessor.php @@ -0,0 +1,141 @@ +. + */ + +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 $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 $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'; + } +} diff --git a/tests/API/Endpoints/LabelEndpointTest.php b/tests/API/Endpoints/LabelEndpointTest.php new file mode 100644 index 00000000..338af836 --- /dev/null +++ b/tests/API/Endpoints/LabelEndpointTest.php @@ -0,0 +1,186 @@ +. + */ + +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); + } +}