mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-01-15 06:39:33 +00:00
Merge tag 'v2.2.1'
# Conflicts: # .docker/symfony.conf # VERSION # templates/projects/build/build.html.twig # templates/projects/info/_builds.html.twig
This commit is contained in:
commit
effe1828a3
498 changed files with 58785 additions and 19864 deletions
|
|
@ -70,4 +70,4 @@ class PropertyMetadataFactory implements PropertyMetadataFactoryInterface
|
|||
|
||||
return $metadata;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@ class CleanAttachmentsCommand extends Command
|
|||
//Ignore image cache folder
|
||||
$finder->exclude('cache');
|
||||
|
||||
//Ignore automigration folder
|
||||
$finder->exclude('.automigration-backup');
|
||||
|
||||
$fs = new Filesystem();
|
||||
|
||||
$file_list = [];
|
||||
|
|
|
|||
136
src/Command/Attachments/DownloadAttachmentsCommand.php
Normal file
136
src/Command/Attachments/DownloadAttachmentsCommand.php
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\Command\Attachments;
|
||||
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\AttachmentUpload;
|
||||
use App\Exceptions\AttachmentDownloadException;
|
||||
use App\Services\Attachments\AttachmentManager;
|
||||
use App\Services\Attachments\AttachmentSubmitHandler;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand('partdb:attachments:download', "Downloads all attachments which have only an external URL to the local filesystem.")]
|
||||
class DownloadAttachmentsCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly AttachmentSubmitHandler $attachmentSubmitHandler,
|
||||
private EntityManagerInterface $entityManager)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function configure(): void
|
||||
{
|
||||
$this->setHelp('This command downloads all attachments, which only have an external URL, to the local filesystem, so that you have an offline copy of the attachments.');
|
||||
$this->addOption('--private', null, null, 'If set, the attachments will be downloaded to the private storage.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$qb = $this->entityManager->createQueryBuilder();
|
||||
$qb->select('attachment')
|
||||
->from(Attachment::class, 'attachment')
|
||||
->where('attachment.external_path IS NOT NULL')
|
||||
->andWhere('attachment.external_path != \'\'')
|
||||
->andWhere('attachment.internal_path IS NULL');
|
||||
|
||||
$query = $qb->getQuery();
|
||||
$attachments = $query->getResult();
|
||||
|
||||
if (count($attachments) === 0) {
|
||||
$io->success('No attachments with external URL found.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->note('Found ' . count($attachments) . ' attachments with external URL, that will be downloaded.');
|
||||
|
||||
//If the option --private is set, the attachments will be downloaded to the private storage.
|
||||
$private = $input->getOption('private');
|
||||
if ($private) {
|
||||
if (!$io->confirm('Attachments will be downloaded to the private storage. Continue?')) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
} else {
|
||||
if (!$io->confirm('Attachments will be downloaded to the public storage, where everybody knowing the correct URL can access it. Continue?')){
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$progressBar = $io->createProgressBar(count($attachments));
|
||||
$progressBar->setFormat("%current%/%max% [%bar%] %percent:3s%% %elapsed:16s%/%estimated:-16s% \n%message%");
|
||||
|
||||
$progressBar->setMessage('Starting download...');
|
||||
$progressBar->start();
|
||||
|
||||
|
||||
$errors = [];
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
/** @var Attachment $attachment */
|
||||
$progressBar->setMessage(sprintf('%s (ID: %s) from %s', $attachment->getName(), $attachment->getID(), $attachment->getHost()));
|
||||
$progressBar->advance();
|
||||
|
||||
try {
|
||||
$attachmentUpload = new AttachmentUpload(file: null, downloadUrl: true, private: $private);
|
||||
$this->attachmentSubmitHandler->handleUpload($attachment, $attachmentUpload);
|
||||
|
||||
//Write changes to the database
|
||||
$this->entityManager->flush();
|
||||
} catch (AttachmentDownloadException $e) {
|
||||
$errors[] = [
|
||||
'attachment' => $attachment,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
|
||||
//Fix the line break after the progress bar
|
||||
$io->newLine();
|
||||
$io->newLine();
|
||||
|
||||
if (count($errors) > 0) {
|
||||
$io->warning('Some attachments could not be downloaded:');
|
||||
foreach ($errors as $error) {
|
||||
$io->warning(sprintf("Attachment %s (ID %s) could not be downloaded from %s:\n%s",
|
||||
$error['attachment']->getName(),
|
||||
$error['attachment']->getID(),
|
||||
$error['attachment']->getExternalPath(),
|
||||
$error['error'])
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$io->success('All attachments downloaded successfully.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
90
src/Command/Attachments/SanitizeSVGAttachmentsCommand.php
Normal file
90
src/Command/Attachments/SanitizeSVGAttachmentsCommand.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\Command\Attachments;
|
||||
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Services\Attachments\AttachmentSubmitHandler;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand('partdb:attachments:sanitize-svg', "Sanitize uploaded SVG files.")]
|
||||
class SanitizeSVGAttachmentsCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly AttachmentSubmitHandler $attachmentSubmitHandler, ?string $name = null)
|
||||
{
|
||||
parent::__construct($name);
|
||||
}
|
||||
|
||||
public function configure(): void
|
||||
{
|
||||
$this->setHelp('This command allows to sanitize SVG files uploaded via attachments. This happens automatically since version 1.17.1, this command is intended to be used for older files.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->info('This command will sanitize all uploaded SVG files. This is only required if you have uploaded (untrusted) SVG files before version 1.17.1. If you are running a newer version, you don\'t need to run this command (again).');
|
||||
if (!$io->confirm('Do you want to continue?', false)) {
|
||||
$io->success('Command aborted.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->info('Sanitizing SVG files...');
|
||||
|
||||
//Finding all attachments with svg files
|
||||
$qb = $this->entityManager->createQueryBuilder();
|
||||
$qb->select('a')
|
||||
->from(Attachment::class, 'a')
|
||||
->where('a.internal_path LIKE :pattern ESCAPE \'#\'')
|
||||
->orWhere('a.original_filename LIKE :pattern ESCAPE \'#\'')
|
||||
->setParameter('pattern', '%.svg');
|
||||
|
||||
$attachments = $qb->getQuery()->getResult();
|
||||
$io->note('Found '.count($attachments).' attachments with SVG files.');
|
||||
|
||||
if (count($attachments) === 0) {
|
||||
$io->success('No SVG files found.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->info('Sanitizing SVG files...');
|
||||
$io->progressStart(count($attachments));
|
||||
foreach ($attachments as $attachment) {
|
||||
/** @var Attachment $attachment */
|
||||
$io->note('Sanitizing attachment '.$attachment->getId().' ('.($attachment->getFilename() ?? '???').')');
|
||||
$this->attachmentSubmitHandler->sanitizeSVGAttachment($attachment);
|
||||
$io->progressAdvance();
|
||||
|
||||
}
|
||||
$io->progressFinish();
|
||||
|
||||
$io->success('Sanitization finished. All SVG files have been sanitized.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -69,8 +69,8 @@ class CheckRequirementsCommand extends Command
|
|||
if ($io->isVerbose()) {
|
||||
$io->comment('Checking PHP version...');
|
||||
}
|
||||
//We recommend PHP 8.2, but 8.1 is the minimum
|
||||
if (PHP_VERSION_ID < 80200) {
|
||||
//We recommend PHP 8.2, but 8.2 is the minimum
|
||||
if (PHP_VERSION_ID < 80400) {
|
||||
$io->warning('You are using PHP '. PHP_VERSION .'. This will work, but a newer version is recommended.');
|
||||
} elseif (!$only_issues) {
|
||||
$io->success('PHP version is sufficient.');
|
||||
|
|
@ -84,7 +84,7 @@ class CheckRequirementsCommand extends Command
|
|||
$io->success('You are using a 64-bit system.');
|
||||
}
|
||||
} else {
|
||||
$io->warning('You are using a system with an unknown bit size. That is interesting xD');
|
||||
$io->warning(' areYou using a system with an unknown bit size. That is interesting xD');
|
||||
}
|
||||
|
||||
//Check if opcache is enabled
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Command\Currencies;
|
||||
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use App\Entity\PriceInformations\Currency;
|
||||
use App\Services\Tools\ExchangeRateUpdater;
|
||||
|
|
@ -39,7 +40,7 @@ use function strlen;
|
|||
#[AsCommand('partdb:currencies:update-exchange-rates|partdb:update-exchange-rates|app:update-exchange-rates', 'Updates the currency exchange rates.')]
|
||||
class UpdateExchangeRatesCommand extends Command
|
||||
{
|
||||
public function __construct(protected string $base_current, protected EntityManagerInterface $em, protected ExchangeRateUpdater $exchangeRateUpdater)
|
||||
public function __construct(protected EntityManagerInterface $em, protected ExchangeRateUpdater $exchangeRateUpdater, private readonly LocalizationSettings $localizationSettings)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
|
@ -54,13 +55,13 @@ class UpdateExchangeRatesCommand extends Command
|
|||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
//Check for valid base current
|
||||
if (3 !== strlen($this->base_current)) {
|
||||
if (3 !== strlen($this->localizationSettings->baseCurrency)) {
|
||||
$io->error('Chosen Base current is not valid. Check your settings!');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->note('Update currency exchange rates with base currency: '.$this->base_current);
|
||||
$io->note('Update currency exchange rates with base currency: '.$this->localizationSettings->baseCurrency);
|
||||
|
||||
//Check what currencies we need to update:
|
||||
$iso_code = $input->getArgument('iso_code');
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ class ImportPartKeeprCommand extends Command
|
|||
protected PKDatastructureImporter $datastructureImporter, protected PKPartImporter $partImporter, protected PKImportHelper $importHelper,
|
||||
protected PKOptionalImporter $optionalImporter)
|
||||
{
|
||||
parent::__construct(self::$defaultName);
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
|
|
|
|||
|
|
@ -39,14 +39,7 @@ final class UpgradePermissionsSchemaCommand extends Command
|
|||
{
|
||||
public function __construct(private readonly PermissionSchemaUpdater $permissionSchemaUpdater, private readonly EntityManagerInterface $em, private readonly EventCommentHelper $eventCommentHelper)
|
||||
{
|
||||
parent::__construct(self::$defaultName);
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setDescription(self::$defaultDescription)
|
||||
;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class UsersPermissionsCommand extends Command
|
|||
{
|
||||
$this->userRepository = $entityManager->getRepository(User::class);
|
||||
|
||||
parent::__construct(self::$defaultName);
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
|
|
|
|||
|
|
@ -24,10 +24,12 @@ namespace App\Controller;
|
|||
|
||||
use App\DataTables\AttachmentDataTable;
|
||||
use App\DataTables\Filters\AttachmentFilter;
|
||||
use App\DataTables\PartsDataTable;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Form\Filters\AttachmentFilterType;
|
||||
use App\Services\Attachments\AttachmentManager;
|
||||
use App\Services\Trees\NodesListBuilder;
|
||||
use App\Settings\BehaviorSettings\TableSettings;
|
||||
use Omines\DataTablesBundle\DataTableFactory;
|
||||
use RuntimeException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
|
|
@ -98,7 +100,8 @@ class AttachmentFileController extends AbstractController
|
|||
}
|
||||
|
||||
#[Route(path: '/attachment/list', name: 'attachment_list')]
|
||||
public function attachmentsTable(Request $request, DataTableFactory $dataTableFactory, NodesListBuilder $nodesListBuilder): Response
|
||||
public function attachmentsTable(Request $request, DataTableFactory $dataTableFactory, NodesListBuilder $nodesListBuilder,
|
||||
TableSettings $tableSettings): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@attachments.list_attachments');
|
||||
|
||||
|
|
@ -110,7 +113,7 @@ class AttachmentFileController extends AbstractController
|
|||
|
||||
$filterForm->handleRequest($formRequest);
|
||||
|
||||
$table = $dataTableFactory->createFromType(AttachmentDataTable::class, ['filter' => $filter])
|
||||
$table = $dataTableFactory->createFromType(AttachmentDataTable::class, ['filter' => $filter], ['pageLength' => $tableSettings->fullDefaultPageSize, 'lengthMenu' => PartsDataTable::LENGTH_MENU])
|
||||
->handleRequest($request);
|
||||
|
||||
if ($table->isCallback()) {
|
||||
|
|
|
|||
588
src/Controller/BulkInfoProviderImportController.php
Normal file
588
src/Controller/BulkInfoProviderImportController.php
Normal file
|
|
@ -0,0 +1,588 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 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\Controller;
|
||||
|
||||
use App\Entity\InfoProviderSystem\BulkImportJobStatus;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\Supplier;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Form\InfoProviderSystem\GlobalFieldMappingType;
|
||||
use App\Services\InfoProviderSystem\BulkInfoProviderService;
|
||||
use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\BulkSearchPartResultsDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/tools/bulk_info_provider_import')]
|
||||
class BulkInfoProviderImportController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BulkInfoProviderService $bulkService,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
#[Autowire(param: 'partdb.bulk_import.batch_size')]
|
||||
private readonly int $bulkImportBatchSize,
|
||||
#[Autowire(param: 'partdb.bulk_import.max_parts_per_operation')]
|
||||
private readonly int $bulkImportMaxParts
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert field mappings from array format to FieldMappingDTO[].
|
||||
*
|
||||
* @param array $fieldMappings Array of field mapping arrays
|
||||
* @return BulkSearchFieldMappingDTO[] Array of FieldMappingDTO objects
|
||||
*/
|
||||
private function convertFieldMappingsToDto(array $fieldMappings): array
|
||||
{
|
||||
$dtos = [];
|
||||
foreach ($fieldMappings as $mapping) {
|
||||
$dtos[] = new BulkSearchFieldMappingDTO(field: $mapping['field'], providers: $mapping['providers'], priority: $mapping['priority'] ?? 1);
|
||||
}
|
||||
return $dtos;
|
||||
}
|
||||
|
||||
private function createErrorResponse(string $message, int $statusCode = 400, array $context = []): JsonResponse
|
||||
{
|
||||
$this->logger->warning('Bulk import operation failed', array_merge([
|
||||
'error' => $message,
|
||||
'user' => $this->getUser()?->getUserIdentifier(),
|
||||
], $context));
|
||||
|
||||
return $this->json([
|
||||
'success' => false,
|
||||
'error' => $message
|
||||
], $statusCode);
|
||||
}
|
||||
|
||||
private function validateJobAccess(int $jobId): ?BulkInfoProviderImportJob
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
$job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
|
||||
|
||||
if (!$job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($job->getCreatedBy() !== $this->getUser()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $job;
|
||||
}
|
||||
|
||||
private function updatePartSearchResults(BulkInfoProviderImportJob $job, ?BulkSearchPartResultsDTO $newResults): void
|
||||
{
|
||||
if ($newResults === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only deserialize and update if we have new results
|
||||
$allResults = $job->getSearchResults($this->entityManager);
|
||||
|
||||
// Find and update the results for this specific part
|
||||
$allResults = $allResults->replaceResultsForPart($newResults);
|
||||
|
||||
// Save updated results back to job
|
||||
$job->setSearchResults($allResults);
|
||||
}
|
||||
|
||||
#[Route('/step1', name: 'bulk_info_provider_step1')]
|
||||
public function step1(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
set_time_limit(600);
|
||||
|
||||
$ids = $request->query->get('ids');
|
||||
if (!$ids) {
|
||||
$this->addFlash('error', 'No parts selected for bulk import');
|
||||
return $this->redirectToRoute('homepage');
|
||||
}
|
||||
|
||||
$partIds = explode(',', $ids);
|
||||
$partRepository = $this->entityManager->getRepository(Part::class);
|
||||
$parts = $partRepository->getElementsFromIDArray($partIds);
|
||||
|
||||
if (empty($parts)) {
|
||||
$this->addFlash('error', 'No valid parts found for bulk import');
|
||||
return $this->redirectToRoute('homepage');
|
||||
}
|
||||
|
||||
// Validate against configured maximum
|
||||
if (count($parts) > $this->bulkImportMaxParts) {
|
||||
$this->addFlash('error', sprintf(
|
||||
'Too many parts selected (%d). Maximum allowed is %d parts per operation.',
|
||||
count($parts),
|
||||
$this->bulkImportMaxParts
|
||||
));
|
||||
return $this->redirectToRoute('homepage');
|
||||
}
|
||||
|
||||
if (count($parts) > ($this->bulkImportMaxParts / 2)) {
|
||||
$this->addFlash('warning', 'Processing ' . count($parts) . ' parts may take several minutes and could timeout. Consider processing smaller batches.');
|
||||
}
|
||||
|
||||
// Generate field choices
|
||||
$fieldChoices = [
|
||||
'info_providers.bulk_search.field.mpn' => 'mpn',
|
||||
'info_providers.bulk_search.field.name' => 'name',
|
||||
];
|
||||
|
||||
// Add dynamic supplier fields
|
||||
$suppliers = $this->entityManager->getRepository(Supplier::class)->findAll();
|
||||
foreach ($suppliers as $supplier) {
|
||||
$supplierKey = strtolower(str_replace([' ', '-', '_'], '_', $supplier->getName()));
|
||||
$fieldChoices["Supplier: " . $supplier->getName() . " (SPN)"] = $supplierKey . '_spn';
|
||||
}
|
||||
|
||||
// Initialize form with useful default mappings
|
||||
$initialData = [
|
||||
'field_mappings' => [
|
||||
['field' => 'mpn', 'providers' => [], 'priority' => 1]
|
||||
],
|
||||
'prefetch_details' => false
|
||||
];
|
||||
|
||||
$form = $this->createForm(GlobalFieldMappingType::class, $initialData, [
|
||||
'field_choices' => $fieldChoices
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
$searchResults = null;
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$formData = $form->getData();
|
||||
$fieldMappingDtos = $this->convertFieldMappingsToDto($formData['field_mappings']);
|
||||
$prefetchDetails = $formData['prefetch_details'] ?? false;
|
||||
|
||||
$user = $this->getUser();
|
||||
if (!$user instanceof User) {
|
||||
throw new \RuntimeException('User must be authenticated and of type User');
|
||||
}
|
||||
|
||||
// Validate part count against configuration limit
|
||||
if (count($parts) > $this->bulkImportMaxParts) {
|
||||
$this->addFlash('error', "Too many parts selected. Maximum allowed: {$this->bulkImportMaxParts}");
|
||||
$partIds = array_map(fn($part) => $part->getId(), $parts);
|
||||
return $this->redirectToRoute('bulk_info_provider_step1', ['ids' => implode(',', $partIds)]);
|
||||
}
|
||||
|
||||
// Create and save the job
|
||||
$job = new BulkInfoProviderImportJob();
|
||||
$job->setFieldMappings($fieldMappingDtos);
|
||||
$job->setPrefetchDetails($prefetchDetails);
|
||||
$job->setCreatedBy($user);
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$jobPart = new BulkInfoProviderImportJobPart($job, $part);
|
||||
$job->addJobPart($jobPart);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($job);
|
||||
$this->entityManager->flush();
|
||||
|
||||
try {
|
||||
$searchResultsDto = $this->bulkService->performBulkSearch($parts, $fieldMappingDtos, $prefetchDetails);
|
||||
|
||||
// Save search results to job
|
||||
$job->setSearchResults($searchResultsDto);
|
||||
$job->markAsInProgress();
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Prefetch details if requested
|
||||
if ($prefetchDetails) {
|
||||
$this->bulkService->prefetchDetailsForResults($searchResultsDto);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $job->getId()]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Critical error during bulk import search', [
|
||||
'job_id' => $job->getId(),
|
||||
'error' => $e->getMessage(),
|
||||
'exception' => $e
|
||||
]);
|
||||
|
||||
$this->entityManager->remove($job);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('error', 'Search failed due to an error: ' . $e->getMessage());
|
||||
$partIds = array_map(fn($part) => $part->getId(), $parts);
|
||||
return $this->redirectToRoute('bulk_info_provider_step1', ['ids' => implode(',', $partIds)]);
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing in-progress jobs for current user
|
||||
$existingJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
|
||||
->findBy(['createdBy' => $this->getUser(), 'status' => BulkImportJobStatus::IN_PROGRESS], ['createdAt' => 'DESC'], 10);
|
||||
|
||||
return $this->render('info_providers/bulk_import/step1.html.twig', [
|
||||
'form' => $form,
|
||||
'parts' => $parts,
|
||||
'search_results' => $searchResults,
|
||||
'existing_jobs' => $existingJobs,
|
||||
'fieldChoices' => $fieldChoices
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/manage', name: 'bulk_info_provider_manage')]
|
||||
public function manageBulkJobs(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
// Get all jobs for current user
|
||||
$allJobs = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
|
||||
->findBy([], ['createdAt' => 'DESC']);
|
||||
|
||||
// Check and auto-complete jobs that should be completed
|
||||
// Also clean up jobs with no results (failed searches)
|
||||
$updatedJobs = false;
|
||||
$jobsToDelete = [];
|
||||
|
||||
foreach ($allJobs as $job) {
|
||||
if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
|
||||
$job->markAsCompleted();
|
||||
$updatedJobs = true;
|
||||
}
|
||||
|
||||
// Mark jobs with no results for deletion (failed searches)
|
||||
if ($job->getResultCount() === 0 && $job->isInProgress()) {
|
||||
$jobsToDelete[] = $job;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete failed jobs
|
||||
foreach ($jobsToDelete as $job) {
|
||||
$this->entityManager->remove($job);
|
||||
$updatedJobs = true;
|
||||
}
|
||||
|
||||
// Flush changes if any jobs were updated
|
||||
if ($updatedJobs) {
|
||||
$this->entityManager->flush();
|
||||
|
||||
if (!empty($jobsToDelete)) {
|
||||
$this->addFlash('info', 'Cleaned up ' . count($jobsToDelete) . ' failed job(s) with no results.');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('info_providers/bulk_import/manage.html.twig', [
|
||||
'jobs' => $this->entityManager->getRepository(BulkInfoProviderImportJob::class)
|
||||
->findBy([], ['createdAt' => 'DESC']) // Refetch after cleanup
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/delete', name: 'bulk_info_provider_delete', methods: ['DELETE'])]
|
||||
public function deleteJob(int $jobId): Response
|
||||
{
|
||||
$job = $this->validateJobAccess($jobId);
|
||||
if (!$job) {
|
||||
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
|
||||
}
|
||||
|
||||
// Only allow deletion of completed, failed, or stopped jobs
|
||||
if (!$job->isCompleted() && !$job->isFailed() && !$job->isStopped()) {
|
||||
return $this->json(['error' => 'Cannot delete active job'], 400);
|
||||
}
|
||||
|
||||
$this->entityManager->remove($job);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/stop', name: 'bulk_info_provider_stop', methods: ['POST'])]
|
||||
public function stopJob(int $jobId): Response
|
||||
{
|
||||
$job = $this->validateJobAccess($jobId);
|
||||
if (!$job) {
|
||||
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
|
||||
}
|
||||
|
||||
// Only allow stopping of pending or in-progress jobs
|
||||
if (!$job->canBeStopped()) {
|
||||
return $this->json(['error' => 'Cannot stop job in current status'], 400);
|
||||
}
|
||||
|
||||
$job->markAsStopped();
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
|
||||
#[Route('/step2/{jobId}', name: 'bulk_info_provider_step2')]
|
||||
public function step2(int $jobId): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
$job = $this->entityManager->getRepository(BulkInfoProviderImportJob::class)->find($jobId);
|
||||
|
||||
if (!$job) {
|
||||
$this->addFlash('error', 'Bulk import job not found');
|
||||
return $this->redirectToRoute('bulk_info_provider_step1');
|
||||
}
|
||||
|
||||
// Check if user owns this job
|
||||
if ($job->getCreatedBy() !== $this->getUser()) {
|
||||
$this->addFlash('error', 'Access denied to this bulk import job');
|
||||
return $this->redirectToRoute('bulk_info_provider_step1');
|
||||
}
|
||||
|
||||
// Get the parts and deserialize search results
|
||||
$parts = $job->getJobParts()->map(fn($jobPart) => $jobPart->getPart())->toArray();
|
||||
$searchResults = $job->getSearchResults($this->entityManager);
|
||||
|
||||
return $this->render('info_providers/bulk_import/step2.html.twig', [
|
||||
'job' => $job,
|
||||
'parts' => $parts,
|
||||
'search_results' => $searchResults,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
#[Route('/job/{jobId}/part/{partId}/mark-completed', name: 'bulk_info_provider_mark_completed', methods: ['POST'])]
|
||||
public function markPartCompleted(int $jobId, int $partId): Response
|
||||
{
|
||||
$job = $this->validateJobAccess($jobId);
|
||||
if (!$job) {
|
||||
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
|
||||
}
|
||||
|
||||
$job->markPartAsCompleted($partId);
|
||||
|
||||
// Auto-complete job if all parts are done
|
||||
if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
|
||||
$job->markAsCompleted();
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'progress' => $job->getProgressPercentage(),
|
||||
'completed_count' => $job->getCompletedPartsCount(),
|
||||
'total_count' => $job->getPartCount(),
|
||||
'job_completed' => $job->isCompleted()
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/part/{partId}/mark-skipped', name: 'bulk_info_provider_mark_skipped', methods: ['POST'])]
|
||||
public function markPartSkipped(int $jobId, int $partId, Request $request): Response
|
||||
{
|
||||
$job = $this->validateJobAccess($jobId);
|
||||
if (!$job) {
|
||||
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
|
||||
}
|
||||
|
||||
$reason = $request->request->get('reason', '');
|
||||
$job->markPartAsSkipped($partId, $reason);
|
||||
|
||||
// Auto-complete job if all parts are done
|
||||
if ($job->isAllPartsCompleted() && !$job->isCompleted()) {
|
||||
$job->markAsCompleted();
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'progress' => $job->getProgressPercentage(),
|
||||
'completed_count' => $job->getCompletedPartsCount(),
|
||||
'skipped_count' => $job->getSkippedPartsCount(),
|
||||
'total_count' => $job->getPartCount(),
|
||||
'job_completed' => $job->isCompleted()
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/part/{partId}/mark-pending', name: 'bulk_info_provider_mark_pending', methods: ['POST'])]
|
||||
public function markPartPending(int $jobId, int $partId): Response
|
||||
{
|
||||
$job = $this->validateJobAccess($jobId);
|
||||
if (!$job) {
|
||||
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
|
||||
}
|
||||
|
||||
$job->markPartAsPending($partId);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'progress' => $job->getProgressPercentage(),
|
||||
'completed_count' => $job->getCompletedPartsCount(),
|
||||
'skipped_count' => $job->getSkippedPartsCount(),
|
||||
'total_count' => $job->getPartCount(),
|
||||
'job_completed' => $job->isCompleted()
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/part/{partId}/research', name: 'bulk_info_provider_research_part', methods: ['POST'])]
|
||||
public function researchPart(int $jobId, int $partId): JsonResponse
|
||||
{
|
||||
$job = $this->validateJobAccess($jobId);
|
||||
if (!$job) {
|
||||
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
|
||||
}
|
||||
|
||||
$part = $this->entityManager->getRepository(Part::class)->find($partId);
|
||||
if (!$part) {
|
||||
return $this->createErrorResponse('Part not found', 404, ['part_id' => $partId]);
|
||||
}
|
||||
|
||||
// Only refresh if the entity might be stale (optional optimization)
|
||||
if ($this->entityManager->getUnitOfWork()->isScheduledForUpdate($part)) {
|
||||
$this->entityManager->refresh($part);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the job's field mappings to perform the search
|
||||
$fieldMappingDtos = $job->getFieldMappings();
|
||||
$prefetchDetails = $job->isPrefetchDetails();
|
||||
|
||||
try {
|
||||
$searchResultsDto = $this->bulkService->performBulkSearch([$part], $fieldMappingDtos, $prefetchDetails);
|
||||
} catch (\Exception $searchException) {
|
||||
// Handle "no search results found" as a normal case, not an error
|
||||
if (str_contains($searchException->getMessage(), 'No search results found')) {
|
||||
$searchResultsDto = null;
|
||||
} else {
|
||||
throw $searchException;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the job's search results for this specific part efficiently
|
||||
$this->updatePartSearchResults($job, $searchResultsDto[0] ?? null);
|
||||
|
||||
// Prefetch details if requested
|
||||
if ($prefetchDetails && $searchResultsDto !== null) {
|
||||
$this->bulkService->prefetchDetailsForResults($searchResultsDto);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Return the new results for this part
|
||||
$newResults = $searchResultsDto[0] ?? null;
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'part_id' => $partId,
|
||||
'results_count' => $newResults ? $newResults->getResultCount() : 0,
|
||||
'errors_count' => $newResults ? $newResults->getErrorCount() : 0,
|
||||
'message' => 'Part research completed successfully'
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return $this->createErrorResponse(
|
||||
'Research failed: ' . $e->getMessage(),
|
||||
500,
|
||||
[
|
||||
'job_id' => $jobId,
|
||||
'part_id' => $partId,
|
||||
'exception' => $e->getMessage()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/job/{jobId}/research-all', name: 'bulk_info_provider_research_all', methods: ['POST'])]
|
||||
public function researchAllParts(int $jobId): JsonResponse
|
||||
{
|
||||
$job = $this->validateJobAccess($jobId);
|
||||
if (!$job) {
|
||||
return $this->createErrorResponse('Job not found or access denied', 404, ['job_id' => $jobId]);
|
||||
}
|
||||
|
||||
// Get all parts that are not completed or skipped
|
||||
$parts = [];
|
||||
foreach ($job->getJobParts() as $jobPart) {
|
||||
if (!$jobPart->isCompleted() && !$jobPart->isSkipped()) {
|
||||
$parts[] = $jobPart->getPart();
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($parts)) {
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'message' => 'No parts to research',
|
||||
'researched_count' => 0
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$fieldMappingDtos = $job->getFieldMappings();
|
||||
$prefetchDetails = $job->isPrefetchDetails();
|
||||
|
||||
// Process in batches to reduce memory usage for large operations
|
||||
$allResults = new BulkSearchResponseDTO(partResults: []);
|
||||
$batches = array_chunk($parts, $this->bulkImportBatchSize);
|
||||
|
||||
foreach ($batches as $batch) {
|
||||
$batchResultsDto = $this->bulkService->performBulkSearch($batch, $fieldMappingDtos, $prefetchDetails);
|
||||
$allResults = BulkSearchResponseDTO::merge($allResults, $batchResultsDto);
|
||||
|
||||
// Properly manage entity manager memory without losing state
|
||||
$jobId = $job->getId();
|
||||
//$this->entityManager->clear(); //TODO: This seems to cause problems with the user relation, when trying to flush later
|
||||
$job = $this->entityManager->find(BulkInfoProviderImportJob::class, $jobId);
|
||||
}
|
||||
|
||||
// Update the job's search results
|
||||
$job->setSearchResults($allResults);
|
||||
|
||||
// Prefetch details if requested
|
||||
if ($prefetchDetails) {
|
||||
$this->bulkService->prefetchDetailsForResults($allResults);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'researched_count' => count($parts),
|
||||
'message' => sprintf('Successfully researched %d parts', count($parts))
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return $this->createErrorResponse(
|
||||
'Bulk research failed: ' . $e->getMessage(),
|
||||
500,
|
||||
[
|
||||
'job_id' => $jobId,
|
||||
'part_count' => count($parts),
|
||||
'exception' => $e->getMessage()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,14 +25,20 @@ namespace App\Controller;
|
|||
|
||||
use App\Entity\Parts\Manufacturer;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Exceptions\OAuthReconnectRequiredException;
|
||||
use App\Form\InfoProviderSystem\PartSearchType;
|
||||
use App\Services\InfoProviderSystem\ExistingPartFinder;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use App\Settings\AppSettings;
|
||||
use App\Settings\InfoProviderSystem\InfoProviderGeneralSettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface;
|
||||
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\HttpClient\Exception\ClientException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
|
@ -46,7 +52,9 @@ class InfoProviderController extends AbstractController
|
|||
|
||||
public function __construct(private readonly ProviderRegistry $providerRegistry,
|
||||
private readonly PartInfoRetriever $infoRetriever,
|
||||
private readonly ExistingPartFinder $existingPartFinder
|
||||
private readonly ExistingPartFinder $existingPartFinder,
|
||||
private readonly SettingsManagerInterface $settingsManager,
|
||||
private readonly SettingsFormFactoryInterface $settingsFormFactory
|
||||
)
|
||||
{
|
||||
|
||||
|
|
@ -63,9 +71,51 @@ class InfoProviderController extends AbstractController
|
|||
]);
|
||||
}
|
||||
|
||||
#[Route('/provider/{provider}/settings', name: 'info_providers_provider_settings')]
|
||||
public function providerSettings(string $provider, Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@config.change_system_settings');
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
$providerInstance = $this->providerRegistry->getProviderByKey($provider);
|
||||
$settingsClass = $providerInstance->getProviderInfo()['settings_class'] ?? throw new \LogicException('Provider ' . $provider . ' does not have a settings class defined');
|
||||
|
||||
//Create a clone of the settings object
|
||||
$settings = $this->settingsManager->createTemporaryCopy($settingsClass);
|
||||
|
||||
//Create a form builder for the settings object
|
||||
$builder = $this->settingsFormFactory->createSettingsFormBuilder($settings);
|
||||
|
||||
//Add a submit button to the form
|
||||
$builder->add('submit', SubmitType::class, ['label' => 'save']);
|
||||
|
||||
//Create the form
|
||||
$form = $builder->getForm();
|
||||
$form->handleRequest($request);
|
||||
|
||||
//If the form was submitted and is valid, save the settings
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->settingsManager->mergeTemporaryCopy($settings);
|
||||
$this->settingsManager->save($settings);
|
||||
|
||||
$this->addFlash('success', t('settings.flash.saved'));
|
||||
}
|
||||
|
||||
if ($form->isSubmitted() && !$form->isValid()) {
|
||||
$this->addFlash('error', t('settings.flash.invalid'));
|
||||
}
|
||||
|
||||
//Render the form
|
||||
return $this->render('info_providers/settings/provider_settings.html.twig', [
|
||||
'form' => $form,
|
||||
'info_provider_key' => $provider,
|
||||
'info_provider_info' => $providerInstance->getProviderInfo(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/search', name: 'info_providers_search')]
|
||||
#[Route('/update/{target}', name: 'info_providers_update_part_search')]
|
||||
public function search(Request $request, #[MapEntity(id: 'target')] ?Part $update_target, LoggerInterface $exceptionLogger): Response
|
||||
public function search(Request $request, #[MapEntity(id: 'target')] ?Part $update_target, LoggerInterface $exceptionLogger, InfoProviderGeneralSettings $infoProviderSettings): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
|
|
@ -96,6 +146,23 @@ class InfoProviderController extends AbstractController
|
|||
}
|
||||
}
|
||||
|
||||
//If the providers form is still empty, use our default value from the settings
|
||||
if (count($form->get('providers')->getData() ?? []) === 0) {
|
||||
$default_providers = $infoProviderSettings->defaultSearchProviders;
|
||||
$provider_objects = [];
|
||||
foreach ($default_providers as $provider_key) {
|
||||
try {
|
||||
$tmp = $this->providerRegistry->getProviderByKey($provider_key);
|
||||
if ($tmp->isActive()) {
|
||||
$provider_objects[] = $tmp;
|
||||
}
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
//If the provider is not found, just ignore it
|
||||
}
|
||||
}
|
||||
$form->get('providers')->setData($provider_objects);
|
||||
}
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$keyword = $form->get('keyword')->getData();
|
||||
$providers = $form->get('providers')->getData();
|
||||
|
|
@ -109,8 +176,11 @@ class InfoProviderController extends AbstractController
|
|||
$this->addFlash('error',$e->getMessage());
|
||||
//Log the exception
|
||||
$exceptionLogger->error('Error during info provider search: ' . $e->getMessage(), ['exception' => $e]);
|
||||
} catch (OAuthReconnectRequiredException $e) {
|
||||
$this->addFlash('error', t('info_providers.search.error.oauth_reconnect', ['%provider%' => $e->getProviderName()]));
|
||||
}
|
||||
|
||||
|
||||
// modify the array to an array of arrays that has a field for a matching local Part
|
||||
// the advantage to use that format even when we don't look for local parts is that we
|
||||
// always work with the same interface
|
||||
|
|
@ -128,4 +198,4 @@ class InfoProviderController extends AbstractController
|
|||
'update_target' => $update_target
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,12 +58,15 @@ use Symfony\Component\Form\FormError;
|
|||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
#[Route(path: '/label')]
|
||||
class LabelController extends AbstractController
|
||||
{
|
||||
public function __construct(protected LabelGenerator $labelGenerator, protected EntityManagerInterface $em, protected ElementTypeNameGenerator $elementTypeNameGenerator, protected RangeParser $rangeParser, protected TranslatorInterface $translator)
|
||||
public function __construct(protected LabelGenerator $labelGenerator, protected EntityManagerInterface $em, protected ElementTypeNameGenerator $elementTypeNameGenerator, protected RangeParser $rangeParser, protected TranslatorInterface $translator,
|
||||
private readonly ValidatorInterface $validator
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +88,7 @@ class LabelController extends AbstractController
|
|||
|
||||
$form = $this->createForm(LabelDialogType::class, null, [
|
||||
'disable_options' => $disable_options,
|
||||
'profile' => $profile
|
||||
]);
|
||||
|
||||
//Try to parse given target_type and target_id
|
||||
|
|
@ -120,13 +124,50 @@ class LabelController extends AbstractController
|
|||
goto render;
|
||||
}
|
||||
|
||||
$profile = new LabelProfile();
|
||||
$profile->setName($form->get('save_profile_name')->getData());
|
||||
$profile->setOptions($form_options);
|
||||
$this->em->persist($profile);
|
||||
$new_profile = new LabelProfile();
|
||||
$new_profile->setName($form->get('save_profile_name')->getData());
|
||||
$new_profile->setOptions($form_options);
|
||||
|
||||
//Validate the profile name
|
||||
$errors = $this->validator->validate($new_profile);
|
||||
if (count($errors) > 0) {
|
||||
foreach ($errors as $error) {
|
||||
$form->get('save_profile_name')->addError(new FormError($error->getMessage()));
|
||||
}
|
||||
goto render;
|
||||
}
|
||||
|
||||
$this->em->persist($new_profile);
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', 'label_generator.profile_saved');
|
||||
|
||||
return $this->redirectToRoute('label_dialog_profile', [
|
||||
'profile' => $new_profile->getID(),
|
||||
'target_id' => (string) $form->get('target_id')->getData()
|
||||
]);
|
||||
}
|
||||
|
||||
//Check if the current profile should be updated
|
||||
if ($form->has('update_profile')
|
||||
&& $form->get('update_profile')->isClicked() //@phpstan-ignore-line Phpstan does not recognize the isClicked method
|
||||
&& $profile instanceof LabelProfile
|
||||
&& $this->isGranted('edit', $profile)) {
|
||||
//Update the profile options
|
||||
$profile->setOptions($form_options);
|
||||
|
||||
//Validate the profile name
|
||||
$errors = $this->validator->validate($profile);
|
||||
if (count($errors) > 0) {
|
||||
foreach ($errors as $error) {
|
||||
$this->addFlash('error', $error->getMessage());
|
||||
}
|
||||
goto render;
|
||||
}
|
||||
|
||||
$this->em->persist($profile);
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', 'label_generator.profile_updated');
|
||||
|
||||
return $this->redirectToRoute('label_dialog_profile', [
|
||||
'profile' => $profile->getID(),
|
||||
'target_id' => (string) $form->get('target_id')->getData()
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ use App\Services\LogSystem\LogEntryExtraFormatter;
|
|||
use App\Services\LogSystem\LogLevelHelper;
|
||||
use App\Services\LogSystem\LogTargetHelper;
|
||||
use App\Services\LogSystem\TimeTravel;
|
||||
use App\Settings\BehaviorSettings\TableSettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Omines\DataTablesBundle\DataTableFactory;
|
||||
|
|
@ -58,7 +59,7 @@ class LogController extends AbstractController
|
|||
}
|
||||
|
||||
#[Route(path: '/', name: 'log_view')]
|
||||
public function showLogs(Request $request, DataTableFactory $dataTable): Response
|
||||
public function showLogs(Request $request, DataTableFactory $dataTable, TableSettings $tableSettings): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.show_logs');
|
||||
|
||||
|
|
@ -72,7 +73,7 @@ class LogController extends AbstractController
|
|||
|
||||
$table = $dataTable->createFromType(LogDataTable::class, [
|
||||
'filter' => $filter,
|
||||
])
|
||||
], ['pageLength' => $tableSettings->fullDefaultPageSize])
|
||||
->handleRequest($request);
|
||||
|
||||
if ($table->isCallback()) {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ use App\Services\Parameters\ParameterExtractor;
|
|||
use App\Services\Parts\PartLotWithdrawAddHelper;
|
||||
use App\Services\Parts\PricedetailHelper;
|
||||
use App\Services\ProjectSystem\ProjectBuildPartHelper;
|
||||
use App\Settings\BehaviorSettings\PartInfoSettings;
|
||||
use DateTime;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
|
|
@ -63,14 +64,17 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
|||
use function Symfony\Component\Translation\t;
|
||||
|
||||
#[Route(path: '/part')]
|
||||
class PartController extends AbstractController
|
||||
final class PartController extends AbstractController
|
||||
{
|
||||
public function __construct(protected PricedetailHelper $pricedetailHelper,
|
||||
protected PartPreviewGenerator $partPreviewGenerator,
|
||||
public function __construct(
|
||||
private readonly PricedetailHelper $pricedetailHelper,
|
||||
private readonly PartPreviewGenerator $partPreviewGenerator,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly AttachmentSubmitHandler $attachmentSubmitHandler, private readonly EntityManagerInterface $em,
|
||||
protected EventCommentHelper $commentHelper)
|
||||
{
|
||||
private readonly AttachmentSubmitHandler $attachmentSubmitHandler,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly EventCommentHelper $commentHelper,
|
||||
private readonly PartInfoSettings $partInfoSettings,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -79,9 +83,16 @@ class PartController extends AbstractController
|
|||
*/
|
||||
#[Route(path: '/{id}/info/{timestamp}', name: 'part_info')]
|
||||
#[Route(path: '/{id}', requirements: ['id' => '\d+'])]
|
||||
public function show(Part $part, Request $request, TimeTravel $timeTravel, HistoryHelper $historyHelper,
|
||||
DataTableFactory $dataTable, ParameterExtractor $parameterExtractor, PartLotWithdrawAddHelper $withdrawAddHelper, ?string $timestamp = null): Response
|
||||
{
|
||||
public function show(
|
||||
Part $part,
|
||||
Request $request,
|
||||
TimeTravel $timeTravel,
|
||||
HistoryHelper $historyHelper,
|
||||
DataTableFactory $dataTable,
|
||||
ParameterExtractor $parameterExtractor,
|
||||
PartLotWithdrawAddHelper $withdrawAddHelper,
|
||||
?string $timestamp = null
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('read', $part);
|
||||
|
||||
$timeTravel_timestamp = null;
|
||||
|
|
@ -119,8 +130,8 @@ class PartController extends AbstractController
|
|||
'pricedetail_helper' => $this->pricedetailHelper,
|
||||
'pictures' => $this->partPreviewGenerator->getPreviewAttachments($part),
|
||||
'timeTravel' => $timeTravel_timestamp,
|
||||
'description_params' => $parameterExtractor->extractParameters($part->getDescription()),
|
||||
'comment_params' => $parameterExtractor->extractParameters($part->getComment()),
|
||||
'description_params' => $this->partInfoSettings->extractParamsFromDescription ? $parameterExtractor->extractParameters($part->getDescription()) : [],
|
||||
'comment_params' => $this->partInfoSettings->extractParamsFromNotes ? $parameterExtractor->extractParameters($part->getComment()) : [],
|
||||
'withdraw_add_helper' => $withdrawAddHelper,
|
||||
]
|
||||
);
|
||||
|
|
@ -131,7 +142,43 @@ class PartController extends AbstractController
|
|||
{
|
||||
$this->denyAccessUnlessGranted('edit', $part);
|
||||
|
||||
return $this->renderPartForm('edit', $request, $part);
|
||||
// Check if this is part of a bulk import job
|
||||
$jobId = $request->query->get('jobId');
|
||||
$bulkJob = null;
|
||||
if ($jobId) {
|
||||
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
|
||||
// Verify user owns this job
|
||||
if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
|
||||
$bulkJob = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->renderPartForm('edit', $request, $part, [], [
|
||||
'bulk_job' => $bulkJob
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/bulk-import-complete/{jobId}', name: 'part_bulk_import_complete', methods: ['POST'])]
|
||||
public function markBulkImportComplete(Part $part, int $jobId, Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('edit', $part);
|
||||
|
||||
if (!$this->isCsrfTokenValid('bulk_complete_' . $part->getId(), $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token');
|
||||
}
|
||||
|
||||
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
|
||||
if (!$bulkJob || $bulkJob->getCreatedBy() !== $this->getUser()) {
|
||||
throw $this->createNotFoundException('Bulk import job not found');
|
||||
}
|
||||
|
||||
$bulkJob->markPartAsCompleted($part->getId());
|
||||
$this->em->persist($bulkJob);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash('success', 'Part marked as completed in bulk import');
|
||||
|
||||
return $this->redirectToRoute('bulk_info_provider_step2', ['jobId' => $jobId]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/delete', name: 'part_delete', methods: ['DELETE'])]
|
||||
|
|
@ -139,7 +186,7 @@ class PartController extends AbstractController
|
|||
{
|
||||
$this->denyAccessUnlessGranted('delete', $part);
|
||||
|
||||
if ($this->isCsrfTokenValid('delete'.$part->getID(), $request->request->get('_token'))) {
|
||||
if ($this->isCsrfTokenValid('delete' . $part->getID(), $request->request->get('_token'))) {
|
||||
|
||||
$this->commentHelper->setMessage($request->request->get('log_comment', null));
|
||||
|
||||
|
|
@ -158,11 +205,15 @@ class PartController extends AbstractController
|
|||
#[Route(path: '/new', name: 'part_new')]
|
||||
#[Route(path: '/{id}/clone', name: 'part_clone')]
|
||||
#[Route(path: '/new_build_part/{project_id}', name: 'part_new_build_part')]
|
||||
public function new(Request $request, EntityManagerInterface $em, TranslatorInterface $translator,
|
||||
AttachmentSubmitHandler $attachmentSubmitHandler, ProjectBuildPartHelper $projectBuildPartHelper,
|
||||
public function new(
|
||||
Request $request,
|
||||
EntityManagerInterface $em,
|
||||
TranslatorInterface $translator,
|
||||
AttachmentSubmitHandler $attachmentSubmitHandler,
|
||||
ProjectBuildPartHelper $projectBuildPartHelper,
|
||||
#[MapEntity(mapping: ['id' => 'id'])] ?Part $part = null,
|
||||
#[MapEntity(mapping: ['project_id' => 'id'])] ?Project $project = null): Response
|
||||
{
|
||||
#[MapEntity(mapping: ['project_id' => 'id'])] ?Project $project = null
|
||||
): Response {
|
||||
|
||||
if ($part instanceof Part) {
|
||||
//Clone part
|
||||
|
|
@ -257,9 +308,14 @@ class PartController extends AbstractController
|
|||
}
|
||||
|
||||
#[Route(path: '/{id}/from_info_provider/{providerKey}/{providerId}/update', name: 'info_providers_update_part', requirements: ['providerId' => '.+'])]
|
||||
public function updateFromInfoProvider(Part $part, Request $request, string $providerKey, string $providerId,
|
||||
PartInfoRetriever $infoRetriever, PartMerger $partMerger): Response
|
||||
{
|
||||
public function updateFromInfoProvider(
|
||||
Part $part,
|
||||
Request $request,
|
||||
string $providerKey,
|
||||
string $providerId,
|
||||
PartInfoRetriever $infoRetriever,
|
||||
PartMerger $partMerger
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('edit', $part);
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
|
|
@ -273,10 +329,22 @@ class PartController extends AbstractController
|
|||
|
||||
$this->addFlash('notice', t('part.merge.flash.please_review'));
|
||||
|
||||
// Check if this is part of a bulk import job
|
||||
$jobId = $request->query->get('jobId');
|
||||
$bulkJob = null;
|
||||
if ($jobId) {
|
||||
$bulkJob = $this->em->getRepository(\App\Entity\InfoProviderSystem\BulkInfoProviderImportJob::class)->find($jobId);
|
||||
// Verify user owns this job
|
||||
if ($bulkJob && $bulkJob->getCreatedBy() !== $this->getUser()) {
|
||||
$bulkJob = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->renderPartForm('update_from_ip', $request, $part, [
|
||||
'info_provider_dto' => $dto,
|
||||
], [
|
||||
'tname_before' => $old_name
|
||||
'tname_before' => $old_name,
|
||||
'bulk_job' => $bulkJob
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -311,7 +379,7 @@ class PartController extends AbstractController
|
|||
} catch (AttachmentDownloadException $attachmentDownloadException) {
|
||||
$this->addFlash(
|
||||
'error',
|
||||
$this->translator->trans('attachment.download_failed').' '.$attachmentDownloadException->getMessage()
|
||||
$this->translator->trans('attachment.download_failed') . ' ' . $attachmentDownloadException->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -352,6 +420,12 @@ class PartController extends AbstractController
|
|||
return $this->redirectToRoute('part_new');
|
||||
}
|
||||
|
||||
// Check if we're in bulk import mode and preserve jobId
|
||||
$jobId = $request->query->get('jobId');
|
||||
if ($jobId && isset($merge_infos['bulk_job'])) {
|
||||
return $this->redirectToRoute('part_edit', ['id' => $new_part->getID(), 'jobId' => $jobId]);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('part_edit', ['id' => $new_part->getID()]);
|
||||
}
|
||||
|
||||
|
|
@ -370,13 +444,17 @@ class PartController extends AbstractController
|
|||
$template = 'parts/edit/update_from_ip.html.twig';
|
||||
}
|
||||
|
||||
return $this->render($template,
|
||||
return $this->render(
|
||||
$template,
|
||||
[
|
||||
'part' => $new_part,
|
||||
'form' => $form,
|
||||
'merge_old_name' => $merge_infos['tname_before'] ?? null,
|
||||
'merge_other' => $merge_infos['other_part'] ?? null
|
||||
]);
|
||||
'merge_other' => $merge_infos['other_part'] ?? null,
|
||||
'bulk_job' => $merge_infos['bulk_job'] ?? null,
|
||||
'jobId' => $request->query->get('jobId')
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -386,17 +464,17 @@ class PartController extends AbstractController
|
|||
if ($this->isCsrfTokenValid('part_withraw' . $part->getID(), $request->request->get('_csfr'))) {
|
||||
//Retrieve partlot from the request
|
||||
$partLot = $em->find(PartLot::class, $request->request->get('lot_id'));
|
||||
if(!$partLot instanceof PartLot) {
|
||||
if (!$partLot instanceof PartLot) {
|
||||
throw new \RuntimeException('Part lot not found!');
|
||||
}
|
||||
//Ensure that the partlot belongs to the part
|
||||
if($partLot->getPart() !== $part) {
|
||||
if ($partLot->getPart() !== $part) {
|
||||
throw new \RuntimeException("The origin partlot does not belong to the part!");
|
||||
}
|
||||
|
||||
//Try to determine the target lot (used for move actions), if the parameter is existing
|
||||
$targetId = $request->request->get('target_id', null);
|
||||
$targetLot = $targetId ? $em->find(PartLot::class, $targetId) : null;
|
||||
$targetLot = $targetId ? $em->find(PartLot::class, $targetId) : null;
|
||||
if ($targetLot && $targetLot->getPart() !== $part) {
|
||||
throw new \RuntimeException("The target partlot does not belong to the part!");
|
||||
}
|
||||
|
|
@ -410,12 +488,12 @@ class PartController extends AbstractController
|
|||
$timestamp = null;
|
||||
$timestamp_str = $request->request->getString('timestamp', '');
|
||||
//Try to parse the timestamp
|
||||
if($timestamp_str !== '') {
|
||||
if ($timestamp_str !== '') {
|
||||
$timestamp = new DateTime($timestamp_str);
|
||||
}
|
||||
|
||||
//Ensure that the timestamp is not in the future
|
||||
if($timestamp !== null && $timestamp > new DateTime("+20min")) {
|
||||
if ($timestamp !== null && $timestamp > new DateTime("+20min")) {
|
||||
throw new \LogicException("The timestamp must not be in the future!");
|
||||
}
|
||||
|
||||
|
|
@ -459,7 +537,7 @@ class PartController extends AbstractController
|
|||
|
||||
err:
|
||||
//If a redirect was passed, then redirect there
|
||||
if($request->request->get('_redirect')) {
|
||||
if ($request->request->get('_redirect')) {
|
||||
return $this->redirect($request->request->get('_redirect'));
|
||||
}
|
||||
//Otherwise just redirect to the part page
|
||||
|
|
|
|||
|
|
@ -29,12 +29,15 @@ use App\DataTables\PartsDataTable;
|
|||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Manufacturer;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\StorageLocation;
|
||||
use App\Entity\Parts\Supplier;
|
||||
use App\Exceptions\InvalidRegexException;
|
||||
use App\Form\Filters\PartFilterType;
|
||||
use App\Services\Parts\PartsTableActionHandler;
|
||||
use App\Services\Trees\NodesListBuilder;
|
||||
use App\Settings\BehaviorSettings\SidebarSettings;
|
||||
use App\Settings\BehaviorSettings\TableSettings;
|
||||
use Doctrine\DBAL\Exception\DriverException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Omines\DataTablesBundle\DataTableFactory;
|
||||
|
|
@ -43,14 +46,32 @@ use Symfony\Component\Form\FormInterface;
|
|||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Translation\TranslatableMessage;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
class PartListsController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly NodesListBuilder $nodesListBuilder, private readonly DataTableFactory $dataTableFactory, private readonly TranslatorInterface $translator)
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager,
|
||||
private readonly NodesListBuilder $nodesListBuilder,
|
||||
private readonly DataTableFactory $dataTableFactory,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly TableSettings $tableSettings,
|
||||
private readonly SidebarSettings $sidebarSettings,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the filter operator to use by default (INCLUDING_CHILDREN or =)
|
||||
* @return string
|
||||
*/
|
||||
private function getFilterOperator(): string
|
||||
{
|
||||
return $this->sidebarSettings->dataStructureNodesTableIncludeChildren ? 'INCLUDING_CHILDREN' : '=';
|
||||
}
|
||||
|
||||
#[Route(path: '/table/action', name: 'table_action', methods: ['POST'])]
|
||||
public function tableAction(Request $request, PartsTableActionHandler $actionHandler): Response
|
||||
{
|
||||
|
|
@ -71,13 +92,32 @@ class PartListsController extends AbstractController
|
|||
if (null === $action || null === $ids) {
|
||||
$this->addFlash('error', 'part.table.actions.no_params_given');
|
||||
} else {
|
||||
$errors = [];
|
||||
|
||||
$parts = $actionHandler->idStringToArray($ids);
|
||||
$redirectResponse = $actionHandler->handleAction($action, $parts, $target ? (int) $target : null, $redirect);
|
||||
$redirectResponse = $actionHandler->handleAction($action, $parts, $target ? (int) $target : null, $redirect, $errors);
|
||||
|
||||
//Save changes
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->addFlash('success', 'part.table.actions.success');
|
||||
if (count($errors) === 0) {
|
||||
$this->addFlash('success', 'part.table.actions.success');
|
||||
} else {
|
||||
$this->addFlash('error', t('part.table.actions.error', ['%count%' => count($errors)]));
|
||||
//Create a flash message for each error
|
||||
foreach ($errors as $error) {
|
||||
/** @var Part $part */
|
||||
$part = $error['part'];
|
||||
|
||||
$this->addFlash('error',
|
||||
t('part.table.actions.error_detail', [
|
||||
'%part_name%' => $part->getName(),
|
||||
'%part_id%' => $part->getID(),
|
||||
'%message%' => $error['message']
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//If the action handler returned a response, we use it, otherwise we redirect back to the previous page.
|
||||
|
|
@ -125,18 +165,21 @@ class PartListsController extends AbstractController
|
|||
$filter_changer($filter);
|
||||
}
|
||||
|
||||
$filterForm = $this->createForm(PartFilterType::class, $filter, ['method' => 'GET']);
|
||||
if($form_changer !== null) {
|
||||
$form_changer($filterForm);
|
||||
//If we are in a post request for the tables, we only have to apply the filter form if the submit query param was set
|
||||
//This saves us some time from creating this complicated term on simple list pages, where no special filter is applied
|
||||
$filterForm = null;
|
||||
if ($request->getMethod() !== 'POST' || $request->query->has('part_filter')) {
|
||||
$filterForm = $this->createForm(PartFilterType::class, $filter, ['method' => 'GET']);
|
||||
if ($form_changer !== null) {
|
||||
$form_changer($filterForm);
|
||||
}
|
||||
|
||||
$filterForm->handleRequest($formRequest);
|
||||
}
|
||||
|
||||
$filterForm->handleRequest($formRequest);
|
||||
|
||||
$table = $this->dataTableFactory->createFromType(
|
||||
PartsDataTable::class,
|
||||
array_merge(['filter' => $filter], $additional_table_vars),
|
||||
['lengthMenu' => PartsDataTable::LENGTH_MENU]
|
||||
)
|
||||
$table = $this->dataTableFactory->createFromType(PartsDataTable::class, array_merge(
|
||||
['filter' => $filter], $additional_table_vars),
|
||||
['pageLength' => $this->tableSettings->fullDefaultPageSize, 'lengthMenu' => PartsDataTable::LENGTH_MENU])
|
||||
->handleRequest($request);
|
||||
|
||||
if ($table->isCallback()) {
|
||||
|
|
@ -159,7 +202,7 @@ class PartListsController extends AbstractController
|
|||
|
||||
return $this->render($template, array_merge([
|
||||
'datatable' => $table,
|
||||
'filterForm' => $filterForm->createView(),
|
||||
'filterForm' => $filterForm?->createView(),
|
||||
], $additonal_template_vars));
|
||||
}
|
||||
|
||||
|
|
@ -171,7 +214,7 @@ class PartListsController extends AbstractController
|
|||
return $this->showListWithFilter($request,
|
||||
'parts/lists/category_list.html.twig',
|
||||
function (PartFilter $filter) use ($category) {
|
||||
$filter->category->setOperator('INCLUDING_CHILDREN')->setValue($category);
|
||||
$filter->category->setOperator($this->getFilterOperator())->setValue($category);
|
||||
}, function (FormInterface $filterForm) {
|
||||
$this->disableFormFieldAfterCreation($filterForm->get('category')->get('value'));
|
||||
}, [
|
||||
|
|
@ -189,7 +232,7 @@ class PartListsController extends AbstractController
|
|||
return $this->showListWithFilter($request,
|
||||
'parts/lists/footprint_list.html.twig',
|
||||
function (PartFilter $filter) use ($footprint) {
|
||||
$filter->footprint->setOperator('INCLUDING_CHILDREN')->setValue($footprint);
|
||||
$filter->footprint->setOperator($this->getFilterOperator())->setValue($footprint);
|
||||
}, function (FormInterface $filterForm) {
|
||||
$this->disableFormFieldAfterCreation($filterForm->get('footprint')->get('value'));
|
||||
}, [
|
||||
|
|
@ -207,7 +250,7 @@ class PartListsController extends AbstractController
|
|||
return $this->showListWithFilter($request,
|
||||
'parts/lists/manufacturer_list.html.twig',
|
||||
function (PartFilter $filter) use ($manufacturer) {
|
||||
$filter->manufacturer->setOperator('INCLUDING_CHILDREN')->setValue($manufacturer);
|
||||
$filter->manufacturer->setOperator($this->getFilterOperator())->setValue($manufacturer);
|
||||
}, function (FormInterface $filterForm) {
|
||||
$this->disableFormFieldAfterCreation($filterForm->get('manufacturer')->get('value'));
|
||||
}, [
|
||||
|
|
@ -225,7 +268,7 @@ class PartListsController extends AbstractController
|
|||
return $this->showListWithFilter($request,
|
||||
'parts/lists/store_location_list.html.twig',
|
||||
function (PartFilter $filter) use ($storelocation) {
|
||||
$filter->storelocation->setOperator('INCLUDING_CHILDREN')->setValue($storelocation);
|
||||
$filter->storelocation->setOperator($this->getFilterOperator())->setValue($storelocation);
|
||||
}, function (FormInterface $filterForm) {
|
||||
$this->disableFormFieldAfterCreation($filterForm->get('storelocation')->get('value'));
|
||||
}, [
|
||||
|
|
@ -243,7 +286,7 @@ class PartListsController extends AbstractController
|
|||
return $this->showListWithFilter($request,
|
||||
'parts/lists/supplier_list.html.twig',
|
||||
function (PartFilter $filter) use ($supplier) {
|
||||
$filter->supplier->setOperator('INCLUDING_CHILDREN')->setValue($supplier);
|
||||
$filter->supplier->setOperator($this->getFilterOperator())->setValue($supplier);
|
||||
}, function (FormInterface $filterForm) {
|
||||
$this->disableFormFieldAfterCreation($filterForm->get('supplier')->get('value'));
|
||||
}, [
|
||||
|
|
|
|||
|
|
@ -31,10 +31,12 @@ use App\Form\ProjectSystem\ProjectBuildType;
|
|||
use App\Helpers\Projects\ProjectBuildRequest;
|
||||
use App\Services\ImportExportSystem\BOMImporter;
|
||||
use App\Services\ProjectSystem\ProjectBuildHelper;
|
||||
use App\Settings\BehaviorSettings\TableSettings;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use League\Csv\SyntaxError;
|
||||
use Omines\DataTablesBundle\DataTableFactory;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
|
|
@ -55,11 +57,12 @@ class ProjectController extends AbstractController
|
|||
}
|
||||
|
||||
#[Route(path: '/{id}/info', name: 'project_info', requirements: ['id' => '\d+'])]
|
||||
public function info(Project $project, Request $request, ProjectBuildHelper $buildHelper): Response
|
||||
public function info(Project $project, Request $request, ProjectBuildHelper $buildHelper, TableSettings $tableSettings): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('read', $project);
|
||||
|
||||
$table = $this->dataTableFactory->createFromType(ProjectBomEntriesDataTable::class, ['project' => $project])
|
||||
$table = $this->dataTableFactory->createFromType(ProjectBomEntriesDataTable::class, ['project' => $project],
|
||||
['pageLength' => $tableSettings->fullDefaultPageSize])
|
||||
->handleRequest($request);
|
||||
|
||||
if ($table->isCallback()) {
|
||||
|
|
@ -100,9 +103,14 @@ class ProjectController extends AbstractController
|
|||
$this->addFlash('success', 'project.build.flash.success');
|
||||
|
||||
return $this->redirect(
|
||||
$request->get('_redirect',
|
||||
$this->generateUrl('project_info', ['id' => $project->getID()]
|
||||
)));
|
||||
$request->get(
|
||||
'_redirect',
|
||||
$this->generateUrl(
|
||||
'project_info',
|
||||
['id' => $project->getID()]
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$this->addFlash('error', 'project.build.flash.invalid_input');
|
||||
|
|
@ -118,9 +126,13 @@ class ProjectController extends AbstractController
|
|||
}
|
||||
|
||||
#[Route(path: '/{id}/import_bom', name: 'project_import_bom', requirements: ['id' => '\d+'])]
|
||||
public function importBOM(Request $request, EntityManagerInterface $entityManager, Project $project,
|
||||
BOMImporter $BOMImporter, ValidatorInterface $validator): Response
|
||||
{
|
||||
public function importBOM(
|
||||
Request $request,
|
||||
EntityManagerInterface $entityManager,
|
||||
Project $project,
|
||||
BOMImporter $BOMImporter,
|
||||
ValidatorInterface $validator
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('edit', $project);
|
||||
|
||||
$builder = $this->createFormBuilder();
|
||||
|
|
@ -136,6 +148,8 @@ class ProjectController extends AbstractController
|
|||
'required' => true,
|
||||
'choices' => [
|
||||
'project.bom_import.type.kicad_pcbnew' => 'kicad_pcbnew',
|
||||
'project.bom_import.type.kicad_schematic' => 'kicad_schematic',
|
||||
'project.bom_import.type.generic_csv' => 'generic_csv',
|
||||
]
|
||||
]);
|
||||
$builder->add('clear_existing_bom', CheckboxType::class, [
|
||||
|
|
@ -159,25 +173,40 @@ class ProjectController extends AbstractController
|
|||
$entityManager->flush();
|
||||
}
|
||||
|
||||
$import_type = $form->get('type')->getData();
|
||||
|
||||
try {
|
||||
// For schematic imports, redirect to field mapping step
|
||||
if (in_array($import_type, ['kicad_schematic', 'generic_csv'], true)) {
|
||||
// Store file content and options in session for field mapping step
|
||||
$file_content = $form->get('file')->getData()->getContent();
|
||||
$clear_existing = $form->get('clear_existing_bom')->getData();
|
||||
|
||||
$request->getSession()->set('bom_import_data', $file_content);
|
||||
$request->getSession()->set('bom_import_clear', $clear_existing);
|
||||
|
||||
return $this->redirectToRoute('project_import_bom_map_fields', ['id' => $project->getID()]);
|
||||
}
|
||||
|
||||
// For PCB imports, proceed directly
|
||||
$entries = $BOMImporter->importFileIntoProject($form->get('file')->getData(), $project, [
|
||||
'type' => $form->get('type')->getData(),
|
||||
'type' => $import_type,
|
||||
]);
|
||||
|
||||
//Validate the project entries
|
||||
// Validate the project entries
|
||||
$errors = $validator->validateProperty($project, 'bom_entries');
|
||||
|
||||
//If no validation errors occured, save the changes and redirect to edit page
|
||||
if (count ($errors) === 0) {
|
||||
// If no validation errors occurred, save the changes and redirect to edit page
|
||||
if (count($errors) === 0) {
|
||||
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
|
||||
$entityManager->flush();
|
||||
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
|
||||
}
|
||||
|
||||
//When we get here, there were validation errors
|
||||
// When we get here, there were validation errors
|
||||
$this->addFlash('error', t('project.bom_import.flash.invalid_entries'));
|
||||
|
||||
} catch (\UnexpectedValueException|SyntaxError $e) {
|
||||
} catch (\UnexpectedValueException | SyntaxError $e) {
|
||||
$this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
|
||||
}
|
||||
}
|
||||
|
|
@ -189,11 +218,267 @@ class ProjectController extends AbstractController
|
|||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/import_bom/map_fields', name: 'project_import_bom_map_fields', requirements: ['id' => '\d+'])]
|
||||
public function importBOMMapFields(
|
||||
Request $request,
|
||||
EntityManagerInterface $entityManager,
|
||||
Project $project,
|
||||
BOMImporter $BOMImporter,
|
||||
ValidatorInterface $validator,
|
||||
LoggerInterface $logger
|
||||
): Response {
|
||||
$this->denyAccessUnlessGranted('edit', $project);
|
||||
|
||||
// Get stored data from session
|
||||
$file_content = $request->getSession()->get('bom_import_data');
|
||||
$clear_existing = $request->getSession()->get('bom_import_clear', false);
|
||||
|
||||
|
||||
if (!$file_content) {
|
||||
$this->addFlash('error', 'project.bom_import.flash.session_expired');
|
||||
return $this->redirectToRoute('project_import_bom', ['id' => $project->getID()]);
|
||||
}
|
||||
|
||||
// Detect fields and get suggestions
|
||||
$detected_fields = $BOMImporter->detectFields($file_content);
|
||||
$suggested_mapping = $BOMImporter->getSuggestedFieldMapping($detected_fields);
|
||||
|
||||
// Create mapping of original field names to sanitized field names for template
|
||||
$field_name_mapping = [];
|
||||
foreach ($detected_fields as $field) {
|
||||
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
|
||||
$field_name_mapping[$field] = $sanitized_field;
|
||||
}
|
||||
|
||||
// Create form for field mapping
|
||||
$builder = $this->createFormBuilder();
|
||||
|
||||
// Add delimiter selection
|
||||
$builder->add('delimiter', ChoiceType::class, [
|
||||
'label' => 'project.bom_import.delimiter',
|
||||
'required' => true,
|
||||
'data' => ',',
|
||||
'choices' => [
|
||||
'project.bom_import.delimiter.comma' => ',',
|
||||
'project.bom_import.delimiter.semicolon' => ';',
|
||||
'project.bom_import.delimiter.tab' => "\t",
|
||||
]
|
||||
]);
|
||||
|
||||
// Get dynamic field mapping targets from BOMImporter
|
||||
$available_targets = $BOMImporter->getAvailableFieldTargets();
|
||||
$target_fields = ['project.bom_import.field_mapping.ignore' => ''];
|
||||
|
||||
foreach ($available_targets as $target_key => $target_info) {
|
||||
$target_fields[$target_info['label']] = $target_key;
|
||||
}
|
||||
|
||||
foreach ($detected_fields as $field) {
|
||||
// Sanitize field name for form use - replace invalid characters with underscores
|
||||
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
|
||||
$builder->add('mapping_' . $sanitized_field, ChoiceType::class, [
|
||||
'label' => $field,
|
||||
'required' => false,
|
||||
'choices' => $target_fields,
|
||||
'data' => $suggested_mapping[$field] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
$builder->add('submit', SubmitType::class, [
|
||||
'label' => 'project.bom_import.preview',
|
||||
]);
|
||||
|
||||
$form = $builder->getForm();
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
// Build field mapping array with priority support
|
||||
$field_mapping = [];
|
||||
$field_priorities = [];
|
||||
$delimiter = $form->get('delimiter')->getData();
|
||||
|
||||
foreach ($detected_fields as $field) {
|
||||
$sanitized_field = preg_replace('/[^a-zA-Z0-9_-]/', '_', $field);
|
||||
$target = $form->get('mapping_' . $sanitized_field)->getData();
|
||||
if (!empty($target)) {
|
||||
$field_mapping[$field] = $target;
|
||||
|
||||
// Get priority from request (default to 10)
|
||||
$priority = $request->request->get('priority_' . $sanitized_field, 10);
|
||||
$field_priorities[$field] = (int) $priority;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate field mapping
|
||||
$validation = $BOMImporter->validateFieldMapping($field_mapping, $detected_fields);
|
||||
|
||||
if (!$validation['is_valid']) {
|
||||
foreach ($validation['errors'] as $error) {
|
||||
$this->addFlash('error', $error);
|
||||
}
|
||||
foreach ($validation['warnings'] as $warning) {
|
||||
$this->addFlash('warning', $warning);
|
||||
}
|
||||
|
||||
return $this->render('projects/import_bom_map_fields.html.twig', [
|
||||
'project' => $project,
|
||||
'form' => $form->createView(),
|
||||
'detected_fields' => $detected_fields,
|
||||
'suggested_mapping' => $suggested_mapping,
|
||||
'field_name_mapping' => $field_name_mapping,
|
||||
]);
|
||||
}
|
||||
|
||||
// Show warnings but continue
|
||||
foreach ($validation['warnings'] as $warning) {
|
||||
$this->addFlash('warning', $warning);
|
||||
}
|
||||
|
||||
try {
|
||||
// Re-detect fields with chosen delimiter
|
||||
$detected_fields = $BOMImporter->detectFields($file_content, $delimiter);
|
||||
|
||||
// Clear existing BOM entries if requested
|
||||
if ($clear_existing) {
|
||||
$existing_count = $project->getBomEntries()->count();
|
||||
$logger->info('Clearing existing BOM entries', [
|
||||
'existing_count' => $existing_count,
|
||||
'project_id' => $project->getID(),
|
||||
]);
|
||||
$project->getBomEntries()->clear();
|
||||
$entityManager->flush();
|
||||
$logger->info('Existing BOM entries cleared');
|
||||
} else {
|
||||
$existing_count = $project->getBomEntries()->count();
|
||||
$logger->info('Keeping existing BOM entries', [
|
||||
'existing_count' => $existing_count,
|
||||
'project_id' => $project->getID(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Validate data before importing
|
||||
$validation_result = $BOMImporter->validateBOMData($file_content, [
|
||||
'type' => 'kicad_schematic',
|
||||
'field_mapping' => $field_mapping,
|
||||
'field_priorities' => $field_priorities,
|
||||
'delimiter' => $delimiter,
|
||||
]);
|
||||
|
||||
// Log validation results
|
||||
$logger->info('BOM import validation completed', [
|
||||
'total_entries' => $validation_result['total_entries'],
|
||||
'valid_entries' => $validation_result['valid_entries'],
|
||||
'invalid_entries' => $validation_result['invalid_entries'],
|
||||
'error_count' => count($validation_result['errors']),
|
||||
'warning_count' => count($validation_result['warnings']),
|
||||
]);
|
||||
|
||||
// Show validation warnings to user
|
||||
foreach ($validation_result['warnings'] as $warning) {
|
||||
$this->addFlash('warning', $warning);
|
||||
}
|
||||
|
||||
// If there are validation errors, show them and stop
|
||||
if (!empty($validation_result['errors'])) {
|
||||
foreach ($validation_result['errors'] as $error) {
|
||||
$this->addFlash('error', $error);
|
||||
}
|
||||
|
||||
return $this->render('projects/import_bom_map_fields.html.twig', [
|
||||
'project' => $project,
|
||||
'form' => $form->createView(),
|
||||
'detected_fields' => $detected_fields,
|
||||
'suggested_mapping' => $suggested_mapping,
|
||||
'field_name_mapping' => $field_name_mapping,
|
||||
'validation_result' => $validation_result,
|
||||
]);
|
||||
}
|
||||
|
||||
// Import with field mapping and priorities (validation already passed)
|
||||
$entries = $BOMImporter->stringToBOMEntries($file_content, [
|
||||
'type' => 'kicad_schematic',
|
||||
'field_mapping' => $field_mapping,
|
||||
'field_priorities' => $field_priorities,
|
||||
'delimiter' => $delimiter,
|
||||
]);
|
||||
|
||||
// Log entry details for debugging
|
||||
$logger->info('BOM entries created', [
|
||||
'total_entries' => count($entries),
|
||||
]);
|
||||
|
||||
foreach ($entries as $index => $entry) {
|
||||
$logger->debug("BOM entry {$index}", [
|
||||
'name' => $entry->getName(),
|
||||
'mountnames' => $entry->getMountnames(),
|
||||
'quantity' => $entry->getQuantity(),
|
||||
'comment' => $entry->getComment(),
|
||||
'part_id' => $entry->getPart()?->getID(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Assign entries to project
|
||||
$logger->info('Adding BOM entries to project', [
|
||||
'entries_count' => count($entries),
|
||||
'project_id' => $project->getID(),
|
||||
]);
|
||||
|
||||
foreach ($entries as $index => $entry) {
|
||||
$logger->debug("Adding BOM entry {$index} to project", [
|
||||
'name' => $entry->getName(),
|
||||
'part_id' => $entry->getPart()?->getID(),
|
||||
'quantity' => $entry->getQuantity(),
|
||||
]);
|
||||
$project->addBomEntry($entry);
|
||||
}
|
||||
|
||||
// Validate the project entries (includes collection constraints)
|
||||
$errors = $validator->validateProperty($project, 'bom_entries');
|
||||
|
||||
// If no validation errors occurred, save and redirect
|
||||
if (count($errors) === 0) {
|
||||
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
|
||||
$entityManager->flush();
|
||||
|
||||
// Clear session data
|
||||
$request->getSession()->remove('bom_import_data');
|
||||
$request->getSession()->remove('bom_import_clear');
|
||||
|
||||
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
|
||||
}
|
||||
|
||||
// When we get here, there were validation errors
|
||||
$this->addFlash('error', t('project.bom_import.flash.invalid_entries'));
|
||||
|
||||
//Print validation errors to log for debugging
|
||||
foreach ($errors as $error) {
|
||||
$logger->error('BOM entry validation error', [
|
||||
'message' => $error->getMessage(),
|
||||
'invalid_value' => $error->getInvalidValue(),
|
||||
]);
|
||||
//And show as flash message
|
||||
$this->addFlash('error', $error->getMessage(),);
|
||||
}
|
||||
|
||||
} catch (\UnexpectedValueException | SyntaxError $e) {
|
||||
$this->addFlash('error', t('project.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('projects/import_bom_map_fields.html.twig', [
|
||||
'project' => $project,
|
||||
'form' => $form,
|
||||
'detected_fields' => $detected_fields,
|
||||
'suggested_mapping' => $suggested_mapping,
|
||||
'field_name_mapping' => $field_name_mapping,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/add_parts', name: 'project_add_parts_no_id')]
|
||||
#[Route(path: '/{id}/add_parts', name: 'project_add_parts', requirements: ['id' => '\d+'])]
|
||||
public function addPart(Request $request, EntityManagerInterface $entityManager, ?Project $project): Response
|
||||
{
|
||||
if($project instanceof Project) {
|
||||
if ($project instanceof Project) {
|
||||
$this->denyAccessUnlessGranted('edit', $project);
|
||||
} else {
|
||||
$this->denyAccessUnlessGranted('@projects.edit');
|
||||
|
|
@ -240,7 +525,7 @@ class ProjectController extends AbstractController
|
|||
|
||||
$data = $form->getData();
|
||||
$bom_entries = $data['bom_entries'];
|
||||
foreach ($bom_entries as $bom_entry){
|
||||
foreach ($bom_entries as $bom_entry) {
|
||||
$target_project->addBOMEntry($bom_entry);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
|||
namespace App\Controller;
|
||||
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use function function_exists;
|
||||
use function in_array;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
|
|
@ -35,7 +36,7 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
|||
*/
|
||||
class RedirectController extends AbstractController
|
||||
{
|
||||
public function __construct(protected string $default_locale, protected TranslatorInterface $translator, protected bool $enforce_index_php)
|
||||
public function __construct(private readonly LocalizationSettings $localizationSettings, protected TranslatorInterface $translator, protected bool $enforce_index_php)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +47,7 @@ class RedirectController extends AbstractController
|
|||
public function addLocalePart(Request $request): RedirectResponse
|
||||
{
|
||||
//By default, we use the global default locale
|
||||
$locale = $this->default_locale;
|
||||
$locale = $this->localizationSettings->locale;
|
||||
|
||||
//Check if a user has set a preferred language setting:
|
||||
$user = $this->getUser();
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ use App\Entity\Parts\Category;
|
|||
use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Manufacturer;
|
||||
use App\Entity\Parts\MeasurementUnit;
|
||||
use App\Entity\Parts\StorageLocation;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Form\Type\Helper\StructuralEntityChoiceHelper;
|
||||
use App\Services\Trees\NodesListBuilder;
|
||||
|
|
@ -78,6 +79,12 @@ class SelectAPIController extends AbstractController
|
|||
return $this->getResponseForClass(Project::class, false);
|
||||
}
|
||||
|
||||
#[Route(path: '/storage_location', name: 'select_storage_location')]
|
||||
public function locations(): Response
|
||||
{
|
||||
return $this->getResponseForClass(StorageLocation::class, true);
|
||||
}
|
||||
|
||||
#[Route(path: '/export_level', name: 'select_export_level')]
|
||||
public function exportLevel(): Response
|
||||
{
|
||||
|
|
|
|||
81
src/Controller/SettingsController.php
Normal file
81
src/Controller/SettingsController.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 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\Controller;
|
||||
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use App\Settings\AppSettings;
|
||||
use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface;
|
||||
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Contracts\Cache\TagAwareCacheInterface;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
class SettingsController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly SettingsManagerInterface $settingsManager, private readonly SettingsFormFactoryInterface $settingsFormFactory)
|
||||
{}
|
||||
|
||||
#[Route("/settings", name: "system_settings")]
|
||||
public function systemSettings(Request $request, TagAwareCacheInterface $cache): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@config.change_system_settings');
|
||||
|
||||
//Create a clone of the settings object
|
||||
$settings = $this->settingsManager->createTemporaryCopy(AppSettings::class);
|
||||
|
||||
//Create a form builder for the settings object
|
||||
$builder = $this->settingsFormFactory->createSettingsFormBuilder($settings);
|
||||
|
||||
//Add a submit button to the form
|
||||
$builder->add('submit', SubmitType::class, ['label' => 'save']);
|
||||
|
||||
//Create the form
|
||||
$form = $builder->getForm();
|
||||
$form->handleRequest($request);
|
||||
|
||||
//If the form was submitted and is valid, save the settings
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->settingsManager->mergeTemporaryCopy($settings);
|
||||
$this->settingsManager->save($settings);
|
||||
|
||||
//It might be possible, that the tree settings have changed, so clear the cache
|
||||
$cache->invalidateTags(['tree_treeview', 'sidebar_tree_update']);
|
||||
|
||||
$this->addFlash('success', t('settings.flash.saved'));
|
||||
}
|
||||
|
||||
if ($form->isSubmitted() && !$form->isValid()) {
|
||||
$this->addFlash('error', t('settings.flash.invalid'));
|
||||
}
|
||||
|
||||
//Render the form
|
||||
return $this->render('settings/settings.html.twig', [
|
||||
'form' => $form
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ use App\Services\Doctrine\DBInfoHelper;
|
|||
use App\Services\Doctrine\NatsortDebugHelper;
|
||||
use App\Services\Misc\GitVersionInfo;
|
||||
use App\Services\System\UpdateAvailableManager;
|
||||
use App\Settings\AppSettings;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
|
@ -47,7 +48,8 @@ class ToolsController extends AbstractController
|
|||
|
||||
#[Route(path: '/server_infos', name: 'tools_server_infos')]
|
||||
public function systemInfos(GitVersionInfo $versionInfo, DBInfoHelper $DBInfoHelper, NatsortDebugHelper $natsortDebugHelper,
|
||||
AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableManager $updateAvailableManager): Response
|
||||
AttachmentSubmitHandler $attachmentSubmitHandler, UpdateAvailableManager $updateAvailableManager,
|
||||
AppSettings $settings): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@system.server_infos');
|
||||
|
||||
|
|
@ -55,23 +57,23 @@ class ToolsController extends AbstractController
|
|||
//Part-DB section
|
||||
'git_branch' => $versionInfo->getGitBranchName(),
|
||||
'git_commit' => $versionInfo->getGitCommitHash(),
|
||||
'default_locale' => $this->getParameter('partdb.locale'),
|
||||
'default_timezone' => $this->getParameter('partdb.timezone'),
|
||||
'default_currency' => $this->getParameter('partdb.default_currency'),
|
||||
'default_theme' => $this->getParameter('partdb.global_theme'),
|
||||
'default_locale' => $settings->system->localization->locale,
|
||||
'default_timezone' => $settings->system->localization->timezone,
|
||||
'default_currency' => $settings->system->localization->baseCurrency,
|
||||
'default_theme' => $settings->system->customization->theme,
|
||||
'enabled_locales' => $this->getParameter('partdb.locale_menu'),
|
||||
'demo_mode' => $this->getParameter('partdb.demo_mode'),
|
||||
'use_gravatar' => $settings->system->privacy->useGravatar,
|
||||
'gdpr_compliance' => $this->getParameter('partdb.gdpr_compliance'),
|
||||
'use_gravatar' => $this->getParameter('partdb.users.use_gravatar'),
|
||||
'email_password_reset' => $this->getParameter('partdb.users.email_pw_reset'),
|
||||
'environment' => $this->getParameter('kernel.environment'),
|
||||
'is_debug' => $this->getParameter('kernel.debug'),
|
||||
'email_sender' => $this->getParameter('partdb.mail.sender_email'),
|
||||
'email_sender_name' => $this->getParameter('partdb.mail.sender_name'),
|
||||
'allow_attachments_downloads' => $this->getParameter('partdb.attachments.allow_downloads'),
|
||||
'allow_attachments_downloads' => $settings->system->attachments->allowDownloads,
|
||||
'detailed_error_pages' => $this->getParameter('partdb.error_pages.show_help'),
|
||||
'error_page_admin_email' => $this->getParameter('partdb.error_pages.admin_email'),
|
||||
'configured_max_file_size' => $this->getParameter('partdb.attachments.max_file_size'),
|
||||
'configured_max_file_size' => $settings->system->attachments->maxFileSize,
|
||||
'effective_max_file_size' => $attachmentSubmitHandler->getMaximumAllowedUploadSize(),
|
||||
'saml_enabled' => $this->getParameter('partdb.saml.enabled'),
|
||||
|
||||
|
|
|
|||
64
src/DataFixtures/CurrencyFixtures.php
Normal file
64
src/DataFixtures/CurrencyFixtures.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\DataFixtures;
|
||||
|
||||
use App\Entity\PriceInformations\Currency;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
class CurrencyFixtures extends Fixture
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$currency1 = new Currency();
|
||||
$currency1->setName('US-Dollar');
|
||||
$currency1->setIsoCode('USD');
|
||||
$manager->persist($currency1);
|
||||
|
||||
$currency2 = new Currency();
|
||||
$currency2->setName('Swiss Franc');
|
||||
$currency2->setIsoCode('CHF');
|
||||
$currency2->setExchangeRate(BigDecimal::of('0.91'));
|
||||
$manager->persist($currency2);
|
||||
|
||||
$currency3 = new Currency();
|
||||
$currency3->setName('Great British Pound');
|
||||
$currency3->setIsoCode('GBP');
|
||||
$currency3->setExchangeRate(BigDecimal::of('0.78'));
|
||||
$manager->persist($currency3);
|
||||
|
||||
$currency7 = new Currency();
|
||||
$currency7->setName('Test Currency with long name');
|
||||
$currency7->setIsoCode('CNY');
|
||||
$manager->persist($currency7);
|
||||
|
||||
$manager->flush();
|
||||
|
||||
|
||||
//Ensure that currency 7 gets ID 7
|
||||
$manager->getRepository(Currency::class)->changeID($currency7, 7);
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace App\DataTables\Filters;
|
||||
|
||||
use App\DataTables\Filters\Constraints\AbstractConstraint;
|
||||
use App\DataTables\Filters\Constraints\BooleanConstraint;
|
||||
use App\DataTables\Filters\Constraints\DateTimeConstraint;
|
||||
use App\DataTables\Filters\Constraints\EntityConstraint;
|
||||
|
|
@ -32,6 +33,7 @@ use App\DataTables\Filters\Constraints\TextConstraint;
|
|||
use App\Entity\Attachments\AttachmentType;
|
||||
use App\Services\Trees\NodesListBuilder;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Omines\DataTablesBundle\Filter\AbstractFilter;
|
||||
|
||||
class AttachmentFilter implements FilterInterface
|
||||
{
|
||||
|
|
@ -51,6 +53,9 @@ class AttachmentFilter implements FilterInterface
|
|||
|
||||
public function __construct(NodesListBuilder $nodesListBuilder)
|
||||
{
|
||||
//Must be done for every new set of attachment filters, to ensure deterministic parameter names.
|
||||
AbstractConstraint::resetParameterCounter();
|
||||
|
||||
$this->dbId = new IntConstraint('attachment.id');
|
||||
$this->name = new TextConstraint('attachment.name');
|
||||
$this->targetType = new InstanceOfConstraint('attachment');
|
||||
|
|
|
|||
|
|
@ -28,10 +28,7 @@ abstract class AbstractConstraint implements FilterInterface
|
|||
{
|
||||
use FilterTrait;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected string $identifier;
|
||||
protected ?string $identifier;
|
||||
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ trait FilterTrait
|
|||
{
|
||||
|
||||
protected bool $useHaving = false;
|
||||
protected static int $parameterCounter = 0;
|
||||
|
||||
public function useHaving($value = true): static
|
||||
{
|
||||
|
|
@ -50,8 +51,18 @@ trait FilterTrait
|
|||
{
|
||||
//Replace all special characters with underscores
|
||||
$property = preg_replace('/\W/', '_', $property);
|
||||
//Add a random number to the end of the property name for uniqueness
|
||||
return $property . '_' . uniqid("", false);
|
||||
return $property . '_' . (self::$parameterCounter++) . '_';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the parameter counter, so the next call to generateParameterIdentifier will start from 0 again.
|
||||
* This should be done before initializing a new set of filters to a fresh query builder, to ensure that the parameter
|
||||
* identifiers are deterministic so that they are cacheable.
|
||||
* @return void
|
||||
*/
|
||||
public static function resetParameterCounter(): void
|
||||
{
|
||||
self::$parameterCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 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\DataTables\Filters\Constraints\Part;
|
||||
|
||||
use App\DataTables\Filters\Constraints\BooleanConstraint;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class BulkImportJobExistsConstraint extends BooleanConstraint
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('bulk_import_job_exists');
|
||||
}
|
||||
|
||||
public function apply(QueryBuilder $queryBuilder): void
|
||||
{
|
||||
// Do not apply a filter if value is null (filter is set to ignore)
|
||||
if (!$this->isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use EXISTS subquery to avoid join conflicts
|
||||
$existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder();
|
||||
$existsSubquery->select('1')
|
||||
->from(BulkInfoProviderImportJobPart::class, 'bip_exists')
|
||||
->where('bip_exists.part = part.id');
|
||||
|
||||
if ($this->value === true) {
|
||||
// Filter for parts that ARE in bulk import jobs
|
||||
$queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')');
|
||||
} else {
|
||||
// Filter for parts that are NOT in bulk import jobs
|
||||
$queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 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\DataTables\Filters\Constraints\Part;
|
||||
|
||||
use App\DataTables\Filters\Constraints\AbstractConstraint;
|
||||
use App\DataTables\Filters\Constraints\ChoiceConstraint;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class BulkImportJobStatusConstraint extends ChoiceConstraint
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('bulk_import_job_status');
|
||||
}
|
||||
|
||||
public function apply(QueryBuilder $queryBuilder): void
|
||||
{
|
||||
// Do not apply a filter if values are empty or operator is null
|
||||
if (!$this->isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use EXISTS subquery to check if part has a job with the specified status(es)
|
||||
$existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder();
|
||||
$existsSubquery->select('1')
|
||||
->from(BulkInfoProviderImportJobPart::class, 'bip_status')
|
||||
->join('bip_status.job', 'job_status')
|
||||
->where('bip_status.part = part.id');
|
||||
|
||||
// Add status conditions based on operator
|
||||
if ($this->operator === 'ANY') {
|
||||
$existsSubquery->andWhere('job_status.status IN (:job_status_values)');
|
||||
$queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')');
|
||||
$queryBuilder->setParameter('job_status_values', $this->value);
|
||||
} elseif ($this->operator === 'NONE') {
|
||||
$existsSubquery->andWhere('job_status.status IN (:job_status_values)');
|
||||
$queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')');
|
||||
$queryBuilder->setParameter('job_status_values', $this->value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 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\DataTables\Filters\Constraints\Part;
|
||||
|
||||
use App\DataTables\Filters\Constraints\ChoiceConstraint;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class BulkImportPartStatusConstraint extends ChoiceConstraint
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('bulk_import_part_status');
|
||||
}
|
||||
|
||||
public function apply(QueryBuilder $queryBuilder): void
|
||||
{
|
||||
// Do not apply a filter if values are empty or operator is null
|
||||
if (!$this->isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use EXISTS subquery to check if part has the specified status(es)
|
||||
$existsSubquery = $queryBuilder->getEntityManager()->createQueryBuilder();
|
||||
$existsSubquery->select('1')
|
||||
->from(BulkInfoProviderImportJobPart::class, 'bip_part_status')
|
||||
->where('bip_part_status.part = part.id');
|
||||
|
||||
// Add status conditions based on operator
|
||||
if ($this->operator === 'ANY') {
|
||||
$existsSubquery->andWhere('bip_part_status.status IN (:part_status_values)');
|
||||
$queryBuilder->andWhere('EXISTS (' . $existsSubquery->getDQL() . ')');
|
||||
$queryBuilder->setParameter('part_status_values', $this->value);
|
||||
} elseif ($this->operator === 'NONE') {
|
||||
$existsSubquery->andWhere('bip_part_status.status IN (:part_status_values)');
|
||||
$queryBuilder->andWhere('NOT EXISTS (' . $existsSubquery->getDQL() . ')');
|
||||
$queryBuilder->setParameter('part_status_values', $this->value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ class TagsConstraint extends AbstractConstraint
|
|||
//Escape any %, _ or \ in the tag
|
||||
$tag = addcslashes($tag, '%_\\');
|
||||
|
||||
$tag_identifier_prefix = uniqid($this->identifier . '_', false);
|
||||
$tag_identifier_prefix = $this->generateParameterIdentifier('tag');
|
||||
|
||||
$expr = $queryBuilder->expr();
|
||||
|
||||
|
|
|
|||
|
|
@ -96,14 +96,15 @@ class TextConstraint extends AbstractConstraint
|
|||
|
||||
//The CONTAINS, LIKE, STARTS and ENDS operators use the LIKE operator, but we have to build the value string differently
|
||||
$like_value = null;
|
||||
$escaped_value = str_replace(['%', '_'], ['\%', '\_'], $this->value);
|
||||
if ($this->operator === 'LIKE') {
|
||||
$like_value = $this->value;
|
||||
$like_value = $this->value; //Here we do not escape anything, as the user may provide % and _ wildcards
|
||||
} elseif ($this->operator === 'STARTS') {
|
||||
$like_value = $this->value . '%';
|
||||
$like_value = $escaped_value . '%';
|
||||
} elseif ($this->operator === 'ENDS') {
|
||||
$like_value = '%' . $this->value;
|
||||
$like_value = '%' . $escaped_value;
|
||||
} elseif ($this->operator === 'CONTAINS') {
|
||||
$like_value = '%' . $this->value . '%';
|
||||
$like_value = '%' . $escaped_value . '%';
|
||||
}
|
||||
|
||||
if ($like_value !== null) {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace App\DataTables\Filters;
|
||||
|
||||
use App\DataTables\Filters\Constraints\AbstractConstraint;
|
||||
use App\DataTables\Filters\Constraints\ChoiceConstraint;
|
||||
use App\DataTables\Filters\Constraints\DateTimeConstraint;
|
||||
use App\DataTables\Filters\Constraints\EntityConstraint;
|
||||
|
|
@ -44,6 +45,9 @@ class LogFilter implements FilterInterface
|
|||
|
||||
public function __construct()
|
||||
{
|
||||
//Must be done for every new set of attachment filters, to ensure deterministic parameter names.
|
||||
AbstractConstraint::resetParameterCounter();
|
||||
|
||||
$this->timestamp = new DateTimeConstraint('log.timestamp');
|
||||
$this->dbId = new IntConstraint('log.id');
|
||||
$this->level = new ChoiceConstraint('log.level');
|
||||
|
|
|
|||
|
|
@ -22,12 +22,16 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace App\DataTables\Filters;
|
||||
|
||||
use App\DataTables\Filters\Constraints\AbstractConstraint;
|
||||
use App\DataTables\Filters\Constraints\BooleanConstraint;
|
||||
use App\DataTables\Filters\Constraints\ChoiceConstraint;
|
||||
use App\DataTables\Filters\Constraints\DateTimeConstraint;
|
||||
use App\DataTables\Filters\Constraints\EntityConstraint;
|
||||
use App\DataTables\Filters\Constraints\IntConstraint;
|
||||
use App\DataTables\Filters\Constraints\NumberConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\BulkImportJobExistsConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\BulkImportJobStatusConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\LessThanDesiredConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\TagsConstraint;
|
||||
|
|
@ -101,8 +105,19 @@ class PartFilter implements FilterInterface
|
|||
public readonly TextConstraint $bomName;
|
||||
public readonly TextConstraint $bomComment;
|
||||
|
||||
/*************************************************
|
||||
* Bulk Import Job tab
|
||||
*************************************************/
|
||||
|
||||
public readonly BulkImportJobExistsConstraint $inBulkImportJob;
|
||||
public readonly BulkImportJobStatusConstraint $bulkImportJobStatus;
|
||||
public readonly BulkImportPartStatusConstraint $bulkImportPartStatus;
|
||||
|
||||
public function __construct(NodesListBuilder $nodesListBuilder)
|
||||
{
|
||||
//Must be done for every new set of attachment filters, to ensure deterministic parameter names.
|
||||
AbstractConstraint::resetParameterCounter();
|
||||
|
||||
$this->name = new TextConstraint('part.name');
|
||||
$this->description = new TextConstraint('part.description');
|
||||
$this->comment = new TextConstraint('part.comment');
|
||||
|
|
@ -126,7 +141,7 @@ class PartFilter implements FilterInterface
|
|||
*/
|
||||
$this->amountSum = (new IntConstraint('(
|
||||
SELECT COALESCE(SUM(__partLot.amount), 0.0)
|
||||
FROM '.PartLot::class.' __partLot
|
||||
FROM ' . PartLot::class . ' __partLot
|
||||
WHERE __partLot.part = part.id
|
||||
AND __partLot.instock_unknown = false
|
||||
AND (__partLot.expiration_date IS NULL OR __partLot.expiration_date > CURRENT_DATE())
|
||||
|
|
@ -162,6 +177,11 @@ class PartFilter implements FilterInterface
|
|||
$this->bomName = new TextConstraint('_projectBomEntries.name');
|
||||
$this->bomComment = new TextConstraint('_projectBomEntries.comment');
|
||||
|
||||
// Bulk Import Job filters
|
||||
$this->inBulkImportJob = new BulkImportJobExistsConstraint();
|
||||
$this->bulkImportJobStatus = new BulkImportJobStatusConstraint();
|
||||
$this->bulkImportPartStatus = new BulkImportPartStatusConstraint();
|
||||
|
||||
}
|
||||
|
||||
public function apply(QueryBuilder $queryBuilder): void
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ declare(strict_types=1);
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
namespace App\DataTables\Filters;
|
||||
use App\DataTables\Filters\Constraints\AbstractConstraint;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class PartSearchFilter implements FilterInterface
|
||||
|
|
@ -143,6 +144,8 @@ class PartSearchFilter implements FilterInterface
|
|||
if ($this->regex) {
|
||||
$queryBuilder->setParameter('search_query', $this->keyword);
|
||||
} else {
|
||||
//Escape % and _ characters in the keyword
|
||||
$this->keyword = str_replace(['%', '_'], ['\%', '\_'], $this->keyword);
|
||||
$queryBuilder->setParameter('search_query', '%' . $this->keyword . '%');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ class ColumnSortHelper
|
|||
* Apply the visibility configuration to the given DataTable and configure the columns.
|
||||
* @param DataTable $dataTable
|
||||
* @param string|array $visible_columns Either a list or a comma separated string of column names, which should
|
||||
* be visible by default. If a column is not listed here, it will be hidden by default.
|
||||
* be visible by default. If a column is not listed here, it will be hidden by default. If an array of enum values are passed,
|
||||
* their value will be used as the column name.
|
||||
* @return void
|
||||
*/
|
||||
public function applyVisibilityAndConfigureColumns(DataTable $dataTable, string|array $visible_columns,
|
||||
|
|
@ -83,6 +84,14 @@ class ColumnSortHelper
|
|||
$visible_columns = array_map(trim(...), explode(",", $visible_columns));
|
||||
}
|
||||
|
||||
//If $visible_columns is a list of enum values, convert them to the column names
|
||||
foreach ($visible_columns as &$value) {
|
||||
if ($value instanceof \BackedEnum) {
|
||||
$value = $value->value;
|
||||
}
|
||||
}
|
||||
unset ($value);
|
||||
|
||||
$processed_columns = [];
|
||||
|
||||
//First add all columns which visibility is not configurable
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ use App\Entity\Parts\PartLot;
|
|||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use App\Services\Formatters\AmountFormatter;
|
||||
use App\Settings\BehaviorSettings\TableSettings;
|
||||
use Doctrine\ORM\AbstractQuery;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
|
||||
|
|
@ -65,8 +66,8 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
private readonly AmountFormatter $amountFormatter,
|
||||
private readonly PartDataTableHelper $partDataTableHelper,
|
||||
private readonly Security $security,
|
||||
private readonly string $visible_columns,
|
||||
private readonly ColumnSortHelper $csh,
|
||||
private readonly TableSettings $tableSettings,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -141,23 +142,25 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
'label' => $this->translator->trans('part.table.storeLocations'),
|
||||
//We need to use a aggregate function to get the first store location, as we have a one-to-many relation
|
||||
'orderField' => 'NATSORT(MIN(_storelocations.name))',
|
||||
'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
|
||||
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
|
||||
], alias: 'storage_location')
|
||||
|
||||
->add('amount', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.amount'),
|
||||
'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderAmount($context),
|
||||
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderAmount($context),
|
||||
'orderField' => 'amountSum'
|
||||
])
|
||||
->add('minamount', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.minamount'),
|
||||
'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format($value,
|
||||
$context->getPartUnit())),
|
||||
'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format(
|
||||
$value,
|
||||
$context->getPartUnit()
|
||||
)),
|
||||
])
|
||||
->add('partUnit', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.partUnit'),
|
||||
'orderField' => 'NATSORT(_partUnit.name)',
|
||||
'render' => function($value, Part $context): string {
|
||||
'render' => function ($value, Part $context): string {
|
||||
$partUnit = $context->getPartUnit();
|
||||
if ($partUnit === null) {
|
||||
return '';
|
||||
|
|
@ -166,7 +169,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
$tmp = htmlspecialchars($partUnit->getName());
|
||||
|
||||
if ($partUnit->getUnit()) {
|
||||
$tmp .= ' ('.htmlspecialchars($partUnit->getUnit()).')';
|
||||
$tmp .= ' (' . htmlspecialchars($partUnit->getUnit()) . ')';
|
||||
}
|
||||
return $tmp;
|
||||
}
|
||||
|
|
@ -229,7 +232,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
}
|
||||
|
||||
if (count($projects) > $max) {
|
||||
$tmp .= ", + ".(count($projects) - $max);
|
||||
$tmp .= ", + " . (count($projects) - $max);
|
||||
}
|
||||
|
||||
return $tmp;
|
||||
|
|
@ -246,7 +249,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
]);
|
||||
|
||||
//Apply the user configured order and visibility and add the columns to the table
|
||||
$this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->visible_columns,
|
||||
$this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->tableSettings->partsDefaultColumns,
|
||||
"TABLE_PARTS_DEFAULT_COLUMNS");
|
||||
|
||||
$dataTable->addOrderBy('name')
|
||||
|
|
@ -365,7 +368,7 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
$builder->addSelect(
|
||||
'(
|
||||
SELECT COALESCE(SUM(partLot.amount), 0.0)
|
||||
FROM '.PartLot::class.' partLot
|
||||
FROM ' . PartLot::class . ' partLot
|
||||
WHERE partLot.part = part.id
|
||||
AND partLot.instock_unknown = false
|
||||
AND (partLot.expiration_date IS NULL OR partLot.expiration_date > CURRENT_DATE())
|
||||
|
|
@ -422,6 +425,13 @@ final class PartsDataTable implements DataTableTypeInterface
|
|||
//Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1
|
||||
//$builder->addGroupBy('_projectBomEntries');
|
||||
}
|
||||
if (str_contains($dql, '_jobPart')) {
|
||||
$builder->leftJoin('part.bulkImportJobParts', '_jobPart');
|
||||
$builder->leftJoin('_jobPart.job', '_bulkImportJob');
|
||||
//Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1
|
||||
//$builder->addGroupBy('_jobPart');
|
||||
//$builder->addGroupBy('_bulkImportJob');
|
||||
}
|
||||
|
||||
return $builder;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ class ILike extends FunctionNode
|
|||
{
|
||||
$platform = $sqlWalker->getConnection()->getDatabasePlatform();
|
||||
|
||||
//
|
||||
if ($platform instanceof AbstractMySQLPlatform || $platform instanceof SQLitePlatform) {
|
||||
$operator = 'LIKE';
|
||||
} elseif ($platform instanceof PostgreSQLPlatform) {
|
||||
|
|
@ -66,6 +65,12 @@ class ILike extends FunctionNode
|
|||
throw new \RuntimeException('Platform ' . gettype($platform) . ' does not support case insensitive like expressions.');
|
||||
}
|
||||
|
||||
return '(' . $this->value->dispatch($sqlWalker) . ' ' . $operator . ' ' . $this->expr->dispatch($sqlWalker) . ')';
|
||||
$escape = "";
|
||||
if ($platform instanceof SQLitePlatform) {
|
||||
//SQLite needs ESCAPE explicitly defined backslash as escape character
|
||||
$escape = " ESCAPE '\\'";
|
||||
}
|
||||
|
||||
return '(' . $this->value->dispatch($sqlWalker) . ' ' . $operator . ' ' . $this->expr->dispatch($sqlWalker) . $escape . ')';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
55
src/Doctrine/Migration/ContainerAwareMigrationFactory.php
Normal file
55
src/Doctrine/Migration/ContainerAwareMigrationFactory.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\Doctrine\Migration;
|
||||
|
||||
use App\Services\UserSystem\PermissionPresetsHelper;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
use Doctrine\Migrations\Version\MigrationFactory;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
#[AsDecorator("doctrine.migrations.migrations_factory")]
|
||||
class ContainerAwareMigrationFactory implements MigrationFactory
|
||||
{
|
||||
public function __construct(private readonly MigrationFactory $decorated,
|
||||
//List all services that should be available in migrations here
|
||||
#[AutowireLocator([
|
||||
PermissionPresetsHelper::class
|
||||
])]
|
||||
private readonly ContainerInterface $container)
|
||||
{
|
||||
}
|
||||
|
||||
public function createVersion(string $migrationClassName): AbstractMigration
|
||||
{
|
||||
$migration = $this->decorated->createVersion($migrationClassName);
|
||||
|
||||
if ($migration instanceof ContainerAwareMigrationInterface) {
|
||||
$migration->setContainer($this->container);
|
||||
}
|
||||
|
||||
return $migration;
|
||||
}
|
||||
}
|
||||
31
src/Doctrine/Migration/ContainerAwareMigrationInterface.php
Normal file
31
src/Doctrine/Migration/ContainerAwareMigrationInterface.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\Doctrine\Migration;
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
interface ContainerAwareMigrationInterface
|
||||
{
|
||||
public function setContainer(?ContainerInterface $container = null): void;
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 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\Doctrine\Types;
|
||||
|
||||
use Doctrine\DBAL\Platforms\AbstractPlatform;
|
||||
use Doctrine\DBAL\Types\Exception\SerializationFailed;
|
||||
use Doctrine\DBAL\Types\Type;
|
||||
use Doctrine\Deprecations\Deprecation;
|
||||
|
||||
use function is_resource;
|
||||
use function restore_error_handler;
|
||||
use function serialize;
|
||||
use function set_error_handler;
|
||||
use function stream_get_contents;
|
||||
use function unserialize;
|
||||
|
||||
use const E_DEPRECATED;
|
||||
use const E_USER_DEPRECATED;
|
||||
|
||||
/**
|
||||
* This class is taken from doctrine ORM 3.8. https://github.com/doctrine/dbal/blob/3.8.x/src/Types/ArrayType.php
|
||||
*
|
||||
* It was removed in doctrine ORM 4.0. However, we require it for backward compatibility with WebauthnKey.
|
||||
* Therefore, we manually added it here as a custom type as a forward compatibility layer.
|
||||
*/
|
||||
class ArrayType extends Type
|
||||
{
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
|
||||
{
|
||||
return $platform->getClobTypeDeclarationSQL($column);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): string
|
||||
{
|
||||
return serialize($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = is_resource($value) ? stream_get_contents($value) : $value;
|
||||
|
||||
set_error_handler(function (int $code, string $message): bool {
|
||||
if ($code === E_DEPRECATED || $code === E_USER_DEPRECATED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//Change to original code. Use SerializationFailed instead of ConversionException.
|
||||
throw new SerializationFailed("Serialization failed (Code $code): " . $message);
|
||||
});
|
||||
|
||||
try {
|
||||
//Change to original code. Use false for allowed_classes, to avoid unsafe unserialization of objects.
|
||||
return unserialize($value, ['allowed_classes' => false]);
|
||||
} finally {
|
||||
restore_error_handler();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return "array";
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
public function requiresSQLCommentHint(AbstractPlatform $platform): bool
|
||||
{
|
||||
Deprecation::triggerIfCalledFromOutside(
|
||||
'doctrine/dbal',
|
||||
'https://github.com/doctrine/dbal/pull/5509',
|
||||
'%s is deprecated.',
|
||||
__METHOD__,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -81,7 +81,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
|
|||
/**
|
||||
* @var string The website of the company
|
||||
*/
|
||||
#[Assert\Url]
|
||||
#[Assert\Url(requireTld: false)]
|
||||
#[Groups(['full', 'company:read', 'company:write', 'import', 'extended'])]
|
||||
#[ORM\Column(type: Types::STRING)]
|
||||
#[Assert\Length(max: 255)]
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ use App\Entity\Attachments\SupplierAttachment;
|
|||
use App\Entity\Attachments\UserAttachment;
|
||||
use App\Entity\Parameters\AbstractParameter;
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\PriceInformations\Pricedetail;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use App\Entity\Parts\Footprint;
|
||||
|
|
@ -67,7 +68,7 @@ use Symfony\Component\Serializer\Annotation\Groups;
|
|||
* Every database table which are managed with this class (or a subclass of it)
|
||||
* must have the table row "id"!! The ID is the unique key to identify the elements.
|
||||
*/
|
||||
#[DiscriminatorMap(typeProperty: 'type', mapping: ['attachment_type' => AttachmentType::class, 'attachment' => Attachment::class, 'attachment_type_attachment' => AttachmentTypeAttachment::class, 'category_attachment' => CategoryAttachment::class, 'currency_attachment' => CurrencyAttachment::class, 'footprint_attachment' => FootprintAttachment::class, 'group_attachment' => GroupAttachment::class, 'label_attachment' => LabelAttachment::class, 'manufacturer_attachment' => ManufacturerAttachment::class, 'measurement_unit_attachment' => MeasurementUnitAttachment::class, 'part_attachment' => PartAttachment::class, 'project_attachment' => ProjectAttachment::class, 'storelocation_attachment' => StorageLocationAttachment::class, 'supplier_attachment' => SupplierAttachment::class, 'user_attachment' => UserAttachment::class, 'category' => Category::class, 'project' => Project::class, 'project_bom_entry' => ProjectBOMEntry::class, 'footprint' => Footprint::class, 'group' => Group::class, 'manufacturer' => Manufacturer::class, 'orderdetail' => Orderdetail::class, 'part' => Part::class, 'pricedetail' => 'App\Entity\PriceInformation\Pricedetail', 'storelocation' => StorageLocation::class, 'part_lot' => PartLot::class, 'currency' => Currency::class, 'measurement_unit' => MeasurementUnit::class, 'parameter' => AbstractParameter::class, 'supplier' => Supplier::class, 'user' => User::class])]
|
||||
#[DiscriminatorMap(typeProperty: 'type', mapping: ['attachment_type' => AttachmentType::class, 'attachment' => Attachment::class, 'attachment_type_attachment' => AttachmentTypeAttachment::class, 'category_attachment' => CategoryAttachment::class, 'currency_attachment' => CurrencyAttachment::class, 'footprint_attachment' => FootprintAttachment::class, 'group_attachment' => GroupAttachment::class, 'label_attachment' => LabelAttachment::class, 'manufacturer_attachment' => ManufacturerAttachment::class, 'measurement_unit_attachment' => MeasurementUnitAttachment::class, 'part_attachment' => PartAttachment::class, 'project_attachment' => ProjectAttachment::class, 'storelocation_attachment' => StorageLocationAttachment::class, 'supplier_attachment' => SupplierAttachment::class, 'user_attachment' => UserAttachment::class, 'category' => Category::class, 'project' => Project::class, 'project_bom_entry' => ProjectBOMEntry::class, 'footprint' => Footprint::class, 'group' => Group::class, 'manufacturer' => Manufacturer::class, 'orderdetail' => Orderdetail::class, 'part' => Part::class, 'pricedetail' => Pricedetail::class, 'storelocation' => StorageLocation::class, 'part_lot' => PartLot::class, 'currency' => Currency::class, 'measurement_unit' => MeasurementUnit::class, 'parameter' => AbstractParameter::class, 'supplier' => Supplier::class, 'user' => User::class])]
|
||||
#[ORM\MappedSuperclass(repositoryClass: DBElementRepository::class)]
|
||||
abstract class AbstractDBElement implements JsonSerializable
|
||||
{
|
||||
|
|
|
|||
|
|
@ -318,6 +318,7 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
|
|||
return new ArrayCollection();
|
||||
}
|
||||
|
||||
//@phpstan-ignore-next-line
|
||||
return $this->children ?? new ArrayCollection();
|
||||
}
|
||||
|
||||
|
|
|
|||
35
src/Entity/InfoProviderSystem/BulkImportJobStatus.php
Normal file
35
src/Entity/InfoProviderSystem/BulkImportJobStatus.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\Entity\InfoProviderSystem;
|
||||
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
enum BulkImportJobStatus: string
|
||||
{
|
||||
case PENDING = 'pending';
|
||||
case IN_PROGRESS = 'in_progress';
|
||||
case COMPLETED = 'completed';
|
||||
case STOPPED = 'stopped';
|
||||
case FAILED = 'failed';
|
||||
}
|
||||
32
src/Entity/InfoProviderSystem/BulkImportPartStatus.php
Normal file
32
src/Entity/InfoProviderSystem/BulkImportPartStatus.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\Entity\InfoProviderSystem;
|
||||
|
||||
|
||||
enum BulkImportPartStatus: string
|
||||
{
|
||||
case PENDING = 'pending';
|
||||
case COMPLETED = 'completed';
|
||||
case SKIPPED = 'skipped';
|
||||
case FAILED = 'failed';
|
||||
}
|
||||
449
src/Entity/InfoProviderSystem/BulkInfoProviderImportJob.php
Normal file
449
src/Entity/InfoProviderSystem/BulkInfoProviderImportJob.php
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 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\Entity\InfoProviderSystem;
|
||||
|
||||
use App\Entity\Base\AbstractDBElement;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Services\InfoProviderSystem\DTOs\BulkSearchFieldMappingDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\BulkSearchResponseDTO;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'bulk_info_provider_import_jobs')]
|
||||
class BulkInfoProviderImportJob extends AbstractDBElement
|
||||
{
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
private string $name = '';
|
||||
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $fieldMappings = [];
|
||||
|
||||
/**
|
||||
* @var BulkSearchFieldMappingDTO[] The deserialized field mappings DTOs, cached for performance
|
||||
*/
|
||||
private ?array $fieldMappingsDTO = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $searchResults = [];
|
||||
|
||||
/**
|
||||
* @var BulkSearchResponseDTO|null The deserialized search results DTO, cached for performance
|
||||
*/
|
||||
private ?BulkSearchResponseDTO $searchResultsDTO = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 20, enumType: BulkImportJobStatus::class)]
|
||||
private BulkImportJobStatus $status = BulkImportJobStatus::PENDING;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $completedAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::BOOLEAN)]
|
||||
private bool $prefetchDetails = false;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?User $createdBy = null;
|
||||
|
||||
/** @var Collection<int, BulkInfoProviderImportJobPart> */
|
||||
#[ORM\OneToMany(targetEntity: BulkInfoProviderImportJobPart::class, mappedBy: 'job', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $jobParts;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
$this->jobParts = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getDisplayNameKey(): string
|
||||
{
|
||||
return 'info_providers.bulk_import.job_name_template';
|
||||
}
|
||||
|
||||
public function getDisplayNameParams(): array
|
||||
{
|
||||
return ['%count%' => $this->getPartCount()];
|
||||
}
|
||||
|
||||
public function getFormattedTimestamp(): string
|
||||
{
|
||||
return $this->createdAt->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
public function setName(string $name): self
|
||||
{
|
||||
$this->name = $name;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getJobParts(): Collection
|
||||
{
|
||||
return $this->jobParts;
|
||||
}
|
||||
|
||||
public function addJobPart(BulkInfoProviderImportJobPart $jobPart): self
|
||||
{
|
||||
if (!$this->jobParts->contains($jobPart)) {
|
||||
$this->jobParts->add($jobPart);
|
||||
$jobPart->setJob($this);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeJobPart(BulkInfoProviderImportJobPart $jobPart): self
|
||||
{
|
||||
if ($this->jobParts->removeElement($jobPart)) {
|
||||
if ($jobPart->getJob() === $this) {
|
||||
$jobPart->setJob(null);
|
||||
}
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPartIds(): array
|
||||
{
|
||||
return $this->jobParts->map(fn($jobPart) => $jobPart->getPart()->getId())->toArray();
|
||||
}
|
||||
|
||||
public function setPartIds(array $partIds): self
|
||||
{
|
||||
// This method is kept for backward compatibility but should be replaced with addJobPart
|
||||
// Clear existing job parts
|
||||
$this->jobParts->clear();
|
||||
|
||||
// Add new job parts (this would need the actual Part entities, not just IDs)
|
||||
// This is a simplified implementation - in practice, you'd want to pass Part entities
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addPart(Part $part): self
|
||||
{
|
||||
$jobPart = new BulkInfoProviderImportJobPart($this, $part);
|
||||
$this->addJobPart($jobPart);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BulkSearchFieldMappingDTO[] The deserialized field mappings
|
||||
*/
|
||||
public function getFieldMappings(): array
|
||||
{
|
||||
if ($this->fieldMappingsDTO === null) {
|
||||
// Lazy load the DTOs from the raw JSON data
|
||||
$this->fieldMappingsDTO = array_map(
|
||||
static fn($data) => BulkSearchFieldMappingDTO::fromSerializableArray($data),
|
||||
$this->fieldMappings
|
||||
);
|
||||
}
|
||||
|
||||
return $this->fieldMappingsDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param BulkSearchFieldMappingDTO[] $fieldMappings
|
||||
* @return $this
|
||||
*/
|
||||
public function setFieldMappings(array $fieldMappings): self
|
||||
{
|
||||
//Ensure that we are dealing with the objects here
|
||||
if (count($fieldMappings) > 0 && !$fieldMappings[0] instanceof BulkSearchFieldMappingDTO) {
|
||||
throw new \InvalidArgumentException('Expected an array of FieldMappingDTO objects');
|
||||
}
|
||||
|
||||
$this->fieldMappingsDTO = $fieldMappings;
|
||||
|
||||
$this->fieldMappings = array_map(
|
||||
static fn(BulkSearchFieldMappingDTO $dto) => $dto->toSerializableArray(),
|
||||
$fieldMappings
|
||||
);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSearchResultsRaw(): array
|
||||
{
|
||||
return $this->searchResults;
|
||||
}
|
||||
|
||||
public function setSearchResultsRaw(array $searchResults): self
|
||||
{
|
||||
$this->searchResults = $searchResults;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setSearchResults(BulkSearchResponseDTO $searchResponse): self
|
||||
{
|
||||
$this->searchResultsDTO = $searchResponse;
|
||||
$this->searchResults = $searchResponse->toSerializableRepresentation();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSearchResults(EntityManagerInterface $entityManager): BulkSearchResponseDTO
|
||||
{
|
||||
if ($this->searchResultsDTO === null) {
|
||||
// Lazy load the DTO from the raw JSON data
|
||||
$this->searchResultsDTO = BulkSearchResponseDTO::fromSerializableRepresentation($this->searchResults, $entityManager);
|
||||
}
|
||||
return $this->searchResultsDTO;
|
||||
}
|
||||
|
||||
public function hasSearchResults(): bool
|
||||
{
|
||||
return !empty($this->searchResults);
|
||||
}
|
||||
|
||||
public function getStatus(): BulkImportJobStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(BulkImportJobStatus $status): self
|
||||
{
|
||||
$this->status = $status;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getCompletedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->completedAt;
|
||||
}
|
||||
|
||||
public function setCompletedAt(?\DateTimeImmutable $completedAt): self
|
||||
{
|
||||
$this->completedAt = $completedAt;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPrefetchDetails(): bool
|
||||
{
|
||||
return $this->prefetchDetails;
|
||||
}
|
||||
|
||||
public function setPrefetchDetails(bool $prefetchDetails): self
|
||||
{
|
||||
$this->prefetchDetails = $prefetchDetails;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedBy(): User
|
||||
{
|
||||
return $this->createdBy;
|
||||
}
|
||||
|
||||
public function setCreatedBy(User $createdBy): self
|
||||
{
|
||||
$this->createdBy = $createdBy;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProgress(): array
|
||||
{
|
||||
$progress = [];
|
||||
foreach ($this->jobParts as $jobPart) {
|
||||
$progressData = [
|
||||
'status' => $jobPart->getStatus()->value
|
||||
];
|
||||
|
||||
// Only include completed_at if it's not null
|
||||
if ($jobPart->getCompletedAt() !== null) {
|
||||
$progressData['completed_at'] = $jobPart->getCompletedAt()->format('c');
|
||||
}
|
||||
|
||||
// Only include reason if it's not null
|
||||
if ($jobPart->getReason() !== null) {
|
||||
$progressData['reason'] = $jobPart->getReason();
|
||||
}
|
||||
|
||||
$progress[$jobPart->getPart()->getId()] = $progressData;
|
||||
}
|
||||
return $progress;
|
||||
}
|
||||
|
||||
public function markAsCompleted(): self
|
||||
{
|
||||
$this->status = BulkImportJobStatus::COMPLETED;
|
||||
$this->completedAt = new \DateTimeImmutable();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsFailed(): self
|
||||
{
|
||||
$this->status = BulkImportJobStatus::FAILED;
|
||||
$this->completedAt = new \DateTimeImmutable();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsStopped(): self
|
||||
{
|
||||
$this->status = BulkImportJobStatus::STOPPED;
|
||||
$this->completedAt = new \DateTimeImmutable();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsInProgress(): self
|
||||
{
|
||||
$this->status = BulkImportJobStatus::IN_PROGRESS;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === BulkImportJobStatus::PENDING;
|
||||
}
|
||||
|
||||
public function isInProgress(): bool
|
||||
{
|
||||
return $this->status === BulkImportJobStatus::IN_PROGRESS;
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === BulkImportJobStatus::COMPLETED;
|
||||
}
|
||||
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this->status === BulkImportJobStatus::FAILED;
|
||||
}
|
||||
|
||||
public function isStopped(): bool
|
||||
{
|
||||
return $this->status === BulkImportJobStatus::STOPPED;
|
||||
}
|
||||
|
||||
public function canBeStopped(): bool
|
||||
{
|
||||
return $this->status === BulkImportJobStatus::PENDING || $this->status === BulkImportJobStatus::IN_PROGRESS;
|
||||
}
|
||||
|
||||
public function getPartCount(): int
|
||||
{
|
||||
return $this->jobParts->count();
|
||||
}
|
||||
|
||||
public function getResultCount(): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($this->searchResults as $partResult) {
|
||||
$count += count($partResult['search_results'] ?? []);
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function markPartAsCompleted(int $partId): self
|
||||
{
|
||||
$jobPart = $this->findJobPartByPartId($partId);
|
||||
if ($jobPart) {
|
||||
$jobPart->markAsCompleted();
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markPartAsSkipped(int $partId, string $reason = ''): self
|
||||
{
|
||||
$jobPart = $this->findJobPartByPartId($partId);
|
||||
if ($jobPart) {
|
||||
$jobPart->markAsSkipped($reason);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markPartAsPending(int $partId): self
|
||||
{
|
||||
$jobPart = $this->findJobPartByPartId($partId);
|
||||
if ($jobPart) {
|
||||
$jobPart->markAsPending();
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPartCompleted(int $partId): bool
|
||||
{
|
||||
$jobPart = $this->findJobPartByPartId($partId);
|
||||
return $jobPart ? $jobPart->isCompleted() : false;
|
||||
}
|
||||
|
||||
public function isPartSkipped(int $partId): bool
|
||||
{
|
||||
$jobPart = $this->findJobPartByPartId($partId);
|
||||
return $jobPart ? $jobPart->isSkipped() : false;
|
||||
}
|
||||
|
||||
public function getCompletedPartsCount(): int
|
||||
{
|
||||
return $this->jobParts->filter(fn($jobPart) => $jobPart->isCompleted())->count();
|
||||
}
|
||||
|
||||
public function getSkippedPartsCount(): int
|
||||
{
|
||||
return $this->jobParts->filter(fn($jobPart) => $jobPart->isSkipped())->count();
|
||||
}
|
||||
|
||||
private function findJobPartByPartId(int $partId): ?BulkInfoProviderImportJobPart
|
||||
{
|
||||
foreach ($this->jobParts as $jobPart) {
|
||||
if ($jobPart->getPart()->getId() === $partId) {
|
||||
return $jobPart;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getProgressPercentage(): float
|
||||
{
|
||||
$total = $this->getPartCount();
|
||||
if ($total === 0) {
|
||||
return 100.0;
|
||||
}
|
||||
|
||||
$completed = $this->getCompletedPartsCount() + $this->getSkippedPartsCount();
|
||||
return round(($completed / $total) * 100, 1);
|
||||
}
|
||||
|
||||
public function isAllPartsCompleted(): bool
|
||||
{
|
||||
$total = $this->getPartCount();
|
||||
if ($total === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$completed = $this->getCompletedPartsCount() + $this->getSkippedPartsCount();
|
||||
return $completed >= $total;
|
||||
}
|
||||
}
|
||||
182
src/Entity/InfoProviderSystem/BulkInfoProviderImportJobPart.php
Normal file
182
src/Entity/InfoProviderSystem/BulkInfoProviderImportJobPart.php
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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);
|
||||
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 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\Entity\InfoProviderSystem;
|
||||
|
||||
use App\Entity\Base\AbstractDBElement;
|
||||
use App\Entity\Parts\Part;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'bulk_info_provider_import_job_parts')]
|
||||
#[ORM\UniqueConstraint(name: 'unique_job_part', columns: ['job_id', 'part_id'])]
|
||||
class BulkInfoProviderImportJobPart extends AbstractDBElement
|
||||
{
|
||||
#[ORM\ManyToOne(targetEntity: BulkInfoProviderImportJob::class, inversedBy: 'jobParts')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private BulkInfoProviderImportJob $job;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'bulkImportJobParts')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private Part $part;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 20, enumType: BulkImportPartStatus::class)]
|
||||
private BulkImportPartStatus $status = BulkImportPartStatus::PENDING;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $reason = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $completedAt = null;
|
||||
|
||||
public function __construct(BulkInfoProviderImportJob $job, Part $part)
|
||||
{
|
||||
$this->job = $job;
|
||||
$this->part = $part;
|
||||
}
|
||||
|
||||
public function getJob(): BulkInfoProviderImportJob
|
||||
{
|
||||
return $this->job;
|
||||
}
|
||||
|
||||
public function setJob(?BulkInfoProviderImportJob $job): self
|
||||
{
|
||||
$this->job = $job;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPart(): Part
|
||||
{
|
||||
return $this->part;
|
||||
}
|
||||
|
||||
public function setPart(?Part $part): self
|
||||
{
|
||||
$this->part = $part;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): BulkImportPartStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(BulkImportPartStatus $status): self
|
||||
{
|
||||
$this->status = $status;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReason(): ?string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
public function setReason(?string $reason): self
|
||||
{
|
||||
$this->reason = $reason;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCompletedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->completedAt;
|
||||
}
|
||||
|
||||
public function setCompletedAt(?\DateTimeImmutable $completedAt): self
|
||||
{
|
||||
$this->completedAt = $completedAt;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsCompleted(): self
|
||||
{
|
||||
$this->status = BulkImportPartStatus::COMPLETED;
|
||||
$this->completedAt = new \DateTimeImmutable();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsSkipped(string $reason = ''): self
|
||||
{
|
||||
$this->status = BulkImportPartStatus::SKIPPED;
|
||||
$this->reason = $reason;
|
||||
$this->completedAt = new \DateTimeImmutable();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsFailed(string $reason = ''): self
|
||||
{
|
||||
$this->status = BulkImportPartStatus::FAILED;
|
||||
$this->reason = $reason;
|
||||
$this->completedAt = new \DateTimeImmutable();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markAsPending(): self
|
||||
{
|
||||
$this->status = BulkImportPartStatus::PENDING;
|
||||
$this->reason = null;
|
||||
$this->completedAt = null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === BulkImportPartStatus::PENDING;
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === BulkImportPartStatus::COMPLETED;
|
||||
}
|
||||
|
||||
public function isSkipped(): bool
|
||||
{
|
||||
return $this->status === BulkImportPartStatus::SKIPPED;
|
||||
}
|
||||
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this->status === BulkImportPartStatus::FAILED;
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,8 @@ namespace App\Entity\LogSystem;
|
|||
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
|
||||
use App\Entity\LabelSystem\LabelProfile;
|
||||
use App\Entity\Parameters\AbstractParameter;
|
||||
use App\Entity\Parts\Category;
|
||||
|
|
@ -67,6 +69,8 @@ enum LogTargetType: int
|
|||
case LABEL_PROFILE = 19;
|
||||
|
||||
case PART_ASSOCIATION = 20;
|
||||
case BULK_INFO_PROVIDER_IMPORT_JOB = 21;
|
||||
case BULK_INFO_PROVIDER_IMPORT_JOB_PART = 22;
|
||||
|
||||
/**
|
||||
* Returns the class name of the target type or null if the target type is NONE.
|
||||
|
|
@ -96,6 +100,8 @@ enum LogTargetType: int
|
|||
self::PARAMETER => AbstractParameter::class,
|
||||
self::LABEL_PROFILE => LabelProfile::class,
|
||||
self::PART_ASSOCIATION => PartAssociation::class,
|
||||
self::BULK_INFO_PROVIDER_IMPORT_JOB => BulkInfoProviderImportJob::class,
|
||||
self::BULK_INFO_PROVIDER_IMPORT_JOB_PART => BulkInfoProviderImportJobPart::class,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
|||
*/
|
||||
#[Groups(['parameter:read', 'full'])]
|
||||
#[SerializedName('formatted')]
|
||||
public function getFormattedValue(): string
|
||||
public function getFormattedValue(bool $latex_formatted = false): string
|
||||
{
|
||||
//If we just only have text value, return early
|
||||
if (null === $this->value_typical && null === $this->value_min && null === $this->value_max) {
|
||||
|
|
@ -217,20 +217,20 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
|||
|
||||
$str = '';
|
||||
$bracket_opened = false;
|
||||
if ($this->value_typical) {
|
||||
$str .= $this->getValueTypicalWithUnit();
|
||||
if ($this->value_typical !== null) {
|
||||
$str .= $this->getValueTypicalWithUnit($latex_formatted);
|
||||
if ($this->value_min || $this->value_max) {
|
||||
$bracket_opened = true;
|
||||
$str .= ' (';
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->value_max && $this->value_min) {
|
||||
$str .= $this->getValueMinWithUnit().' ... '.$this->getValueMaxWithUnit();
|
||||
} elseif ($this->value_max) {
|
||||
$str .= 'max. '.$this->getValueMaxWithUnit();
|
||||
} elseif ($this->value_min) {
|
||||
$str .= 'min. '.$this->getValueMinWithUnit();
|
||||
if ($this->value_max !== null && $this->value_min !== null) {
|
||||
$str .= $this->getValueMinWithUnit($latex_formatted).' ... '.$this->getValueMaxWithUnit($latex_formatted);
|
||||
} elseif ($this->value_max !== null) {
|
||||
$str .= 'max. '.$this->getValueMaxWithUnit($latex_formatted);
|
||||
} elseif ($this->value_min !== null) {
|
||||
$str .= 'min. '.$this->getValueMinWithUnit($latex_formatted);
|
||||
}
|
||||
|
||||
//Add closing bracket
|
||||
|
|
@ -344,25 +344,25 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
|||
/**
|
||||
* Return a formatted version with the minimum value with the unit of this parameter.
|
||||
*/
|
||||
public function getValueTypicalWithUnit(): string
|
||||
public function getValueTypicalWithUnit(bool $with_latex = false): string
|
||||
{
|
||||
return $this->formatWithUnit($this->value_typical);
|
||||
return $this->formatWithUnit($this->value_typical, with_latex: $with_latex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a formatted version with the maximum value with the unit of this parameter.
|
||||
*/
|
||||
public function getValueMaxWithUnit(): string
|
||||
public function getValueMaxWithUnit(bool $with_latex = false): string
|
||||
{
|
||||
return $this->formatWithUnit($this->value_max);
|
||||
return $this->formatWithUnit($this->value_max, with_latex: $with_latex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a formatted version with the typical value with the unit of this parameter.
|
||||
*/
|
||||
public function getValueMinWithUnit(): string
|
||||
public function getValueMinWithUnit(bool $with_latex = false): string
|
||||
{
|
||||
return $this->formatWithUnit($this->value_min);
|
||||
return $this->formatWithUnit($this->value_min, with_latex: $with_latex);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -441,16 +441,26 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
|
|||
/**
|
||||
* Return a string representation and (if possible) with its unit.
|
||||
*/
|
||||
protected function formatWithUnit(float $value, string $format = '%g'): string
|
||||
protected function formatWithUnit(float $value, string $format = '%g', bool $with_latex = false): string
|
||||
{
|
||||
$str = sprintf($format, $value);
|
||||
if ($this->unit !== '') {
|
||||
return $str.' '.$this->unit;
|
||||
|
||||
if (!$with_latex) {
|
||||
$unit = $this->unit;
|
||||
} else {
|
||||
//Escape the percentage sign for convenience (as latex uses it as comment and it is often used in units)
|
||||
$escaped = preg_replace('/\\\\?%/', "\\\\%", $this->unit);
|
||||
|
||||
$unit = '$\mathrm{'.$escaped.'}$';
|
||||
}
|
||||
|
||||
return $str.' '.$unit;
|
||||
}
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the class of the element that is allowed to be associated with this attachment.
|
||||
* @return string
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Entity\Parts;
|
||||
|
||||
use App\ApiPlatform\Filter\TagFilter;
|
||||
use Doctrine\Common\Collections\Criteria;
|
||||
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||
|
|
@ -40,10 +38,12 @@ use ApiPlatform\Serializer\Filter\PropertyFilter;
|
|||
use App\ApiPlatform\Filter\EntityFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\ApiPlatform\Filter\PartStoragelocationFilter;
|
||||
use App\ApiPlatform\Filter\TagFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\AttachmentContainingDBElement;
|
||||
use App\Entity\Attachments\PartAttachment;
|
||||
use App\Entity\EDA\EDAPartInfo;
|
||||
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
|
||||
use App\Entity\Parameters\ParametersTrait;
|
||||
use App\Entity\Parameters\PartParameter;
|
||||
use App\Entity\Parts\PartTraits\AdvancedPropertyTrait;
|
||||
|
|
@ -59,6 +59,7 @@ use App\Repository\PartRepository;
|
|||
use App\Validator\Constraints\UniqueObjectCollection;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\Common\Collections\Criteria;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
|
|
@ -83,8 +84,18 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||
#[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read',
|
||||
'orderdetail:read', 'pricedetail:read', 'parameter:read', 'attachment:read', 'eda_info:read'],
|
||||
new Get(normalizationContext: [
|
||||
'groups' => [
|
||||
'part:read',
|
||||
'provider_reference:read',
|
||||
'api:basic:read',
|
||||
'part_lot:read',
|
||||
'orderdetail:read',
|
||||
'pricedetail:read',
|
||||
'parameter:read',
|
||||
'attachment:read',
|
||||
'eda_info:read'
|
||||
],
|
||||
'openapi_definition_name' => 'Read',
|
||||
], security: 'is_granted("read", object)'),
|
||||
new GetCollection(security: 'is_granted("@parts.read")'),
|
||||
|
|
@ -92,7 +103,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||
new Patch(security: 'is_granted("edit", object)'),
|
||||
new Delete(security: 'is_granted("delete", object)'),
|
||||
],
|
||||
normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'],
|
||||
normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'],
|
||||
denormalizationContext: ['groups' => ['part:write', 'api:basic:write', 'eda_info:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
|
||||
)]
|
||||
#[ApiFilter(PropertyFilter::class)]
|
||||
|
|
@ -100,7 +111,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||
#[ApiFilter(PartStoragelocationFilter::class, properties: ["storage_location"])]
|
||||
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "description", "ipn", "manufacturer_product_number"])]
|
||||
#[ApiFilter(TagFilter::class, properties: ["tags"])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ["favorite" , "needs_review"])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ["favorite", "needs_review"])]
|
||||
#[ApiFilter(RangeFilter::class, properties: ["mass", "minamount"])]
|
||||
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
|
||||
|
|
@ -160,6 +171,12 @@ class Part extends AttachmentContainingDBElement
|
|||
#[Groups(['part:read'])]
|
||||
protected ?\DateTimeImmutable $lastModified = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, BulkInfoProviderImportJobPart>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'part', targetEntity: BulkInfoProviderImportJobPart::class, cascade: ['remove'], orphanRemoval: true)]
|
||||
protected Collection $bulkImportJobParts;
|
||||
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
|
|
@ -172,6 +189,7 @@ class Part extends AttachmentContainingDBElement
|
|||
|
||||
$this->associated_parts_as_owner = new ArrayCollection();
|
||||
$this->associated_parts_as_other = new ArrayCollection();
|
||||
$this->bulkImportJobParts = new ArrayCollection();
|
||||
|
||||
//By default, the part has no provider
|
||||
$this->providerReference = InfoProviderReference::noProvider();
|
||||
|
|
@ -230,4 +248,38 @@ class Part extends AttachmentContainingDBElement
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all bulk import job parts for this part
|
||||
* @return Collection<int, BulkInfoProviderImportJobPart>
|
||||
*/
|
||||
public function getBulkImportJobParts(): Collection
|
||||
{
|
||||
return $this->bulkImportJobParts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a bulk import job part to this part
|
||||
*/
|
||||
public function addBulkImportJobPart(BulkInfoProviderImportJobPart $jobPart): self
|
||||
{
|
||||
if (!$this->bulkImportJobParts->contains($jobPart)) {
|
||||
$this->bulkImportJobParts->add($jobPart);
|
||||
$jobPart->setPart($this);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a bulk import job part from this part
|
||||
*/
|
||||
public function removeBulkImportJobPart(BulkInfoProviderImportJobPart $jobPart): self
|
||||
{
|
||||
if ($this->bulkImportJobParts->removeElement($jobPart)) {
|
||||
if ($jobPart->getPart() === $this) {
|
||||
$jobPart->setPart(null);
|
||||
}
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ trait ManufacturerTrait
|
|||
/**
|
||||
* @var string The url to the part on the manufacturer's homepage
|
||||
*/
|
||||
#[Assert\Url]
|
||||
#[Assert\Url(requireTld: false)]
|
||||
#[Groups(['full', 'import', 'part:read', 'part:write'])]
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
protected string $manufacturer_product_url = '';
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ trait ProjectTrait
|
|||
/**
|
||||
* @var Collection<ProjectBOMEntry> $project_bom_entries
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'part', targetEntity: ProjectBOMEntry::class, cascade: ['remove'], orphanRemoval: true)]
|
||||
#[ORM\OneToMany(targetEntity: ProjectBOMEntry::class, mappedBy: 'part')]
|
||||
protected Collection $project_bom_entries;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
|
|||
/**
|
||||
* @var string The URL to the product on the supplier's website
|
||||
*/
|
||||
#[Assert\Url]
|
||||
#[Assert\Url(requireTld: false)]
|
||||
#[Groups(['full', 'import', 'orderdetail:read', 'orderdetail:write'])]
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
protected string $supplier_product_url = '';
|
||||
|
|
|
|||
35
src/Entity/SettingsEntry.php
Normal file
35
src/Entity/SettingsEntry.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\Entity;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Jbtronics\SettingsBundle\Entity\AbstractSettingsORMEntry;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
class SettingsEntry extends AbstractSettingsORMEntry
|
||||
{
|
||||
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: Types::INTEGER)]
|
||||
protected int $id;
|
||||
}
|
||||
|
|
@ -197,7 +197,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
|
|||
/**
|
||||
* @var string|null The language/locale the user prefers
|
||||
*/
|
||||
#[Assert\Language]
|
||||
#[Assert\Locale]
|
||||
#[Groups(['full', 'import', 'user:read'])]
|
||||
#[ORM\Column(name: 'config_language', type: Types::STRING, nullable: true)]
|
||||
protected ?string $language = '';
|
||||
|
|
|
|||
|
|
@ -100,16 +100,19 @@ class WebauthnKey extends BasePublicKeyCredentialSource implements TimeStampable
|
|||
public static function fromRegistration(BasePublicKeyCredentialSource $registration): self
|
||||
{
|
||||
return new self(
|
||||
$registration->getPublicKeyCredentialId(),
|
||||
$registration->getType(),
|
||||
$registration->getTransports(),
|
||||
$registration->getAttestationType(),
|
||||
$registration->getTrustPath(),
|
||||
$registration->getAaguid(),
|
||||
$registration->getCredentialPublicKey(),
|
||||
$registration->getUserHandle(),
|
||||
$registration->getCounter(),
|
||||
$registration->getOtherUI()
|
||||
publicKeyCredentialId: $registration->publicKeyCredentialId,
|
||||
type: $registration->type,
|
||||
transports: $registration->transports,
|
||||
attestationType: $registration->attestationType,
|
||||
trustPath: $registration->trustPath,
|
||||
aaguid: $registration->aaguid,
|
||||
credentialPublicKey: $registration->credentialPublicKey,
|
||||
userHandle: $registration->userHandle,
|
||||
counter: $registration->counter,
|
||||
otherUI: $registration->otherUI,
|
||||
backupEligible: $registration->backupEligible,
|
||||
backupStatus: $registration->backupStatus,
|
||||
uvInitialized: $registration->uvInitialized,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
59
src/EntityListeners/PartProjectBOMEntryUnlinkListener.php
Normal file
59
src/EntityListeners/PartProjectBOMEntryUnlinkListener.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\EntityListeners;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
|
||||
use Doctrine\ORM\Event\PreRemoveEventArgs;
|
||||
|
||||
/**
|
||||
* If an part is deleted, this listener makes sure that all ProjectBOMEntries that reference this part, are updated
|
||||
* to not reference the part anymore, but instead store the part name in the name field.
|
||||
*/
|
||||
#[AsEntityListener(event: "preRemove", entity: Part::class)]
|
||||
class PartProjectBOMEntryUnlinkListener
|
||||
{
|
||||
public function preRemove(Part $part, PreRemoveEventArgs $event): void
|
||||
{
|
||||
// Iterate over all ProjectBOMEntries that use this part and put the part name into the name field
|
||||
foreach ($part->getProjectBomEntries() as $bom_entry) {
|
||||
$old_name = $bom_entry->getName();
|
||||
if ($old_name === null || trim($old_name) === '') {
|
||||
$bom_entry->setName($part->getName());
|
||||
} else {
|
||||
$bom_entry->setName($old_name . ' (' . $part->getName() . ')');
|
||||
}
|
||||
|
||||
$old_comment = $bom_entry->getComment();
|
||||
if ($old_comment === null || trim($old_comment) === '') {
|
||||
$bom_entry->setComment('Part was deleted: ' . $part->getName());
|
||||
} else {
|
||||
$bom_entry->setComment($old_comment . "\n\n Part was deleted: " . $part->getName());
|
||||
}
|
||||
|
||||
//Remove the part reference
|
||||
$bom_entry->setPart(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,8 @@ use App\Services\LogSystem\EventCommentHelper;
|
|||
use App\Services\LogSystem\EventLogger;
|
||||
use App\Services\LogSystem\EventUndoHelper;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use App\Settings\SystemSettings\HistorySettings;
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Event\PostFlushEventArgs;
|
||||
|
|
@ -74,14 +76,15 @@ class EventLoggerListener
|
|||
];
|
||||
|
||||
protected const MAX_STRING_LENGTH = 2000;
|
||||
protected bool $save_new_data;
|
||||
|
||||
public function __construct(protected EventLogger $logger, protected SerializerInterface $serializer, protected EventCommentHelper $eventCommentHelper,
|
||||
protected bool $save_changed_fields, protected bool $save_changed_data, protected bool $save_removed_data, bool $save_new_data,
|
||||
protected PropertyAccessorInterface $propertyAccessor, protected EventUndoHelper $eventUndoHelper)
|
||||
public function __construct(
|
||||
protected EventLogger $logger,
|
||||
protected SerializerInterface $serializer,
|
||||
protected EventCommentHelper $eventCommentHelper,
|
||||
private readonly HistorySettings $settings,
|
||||
protected PropertyAccessorInterface $propertyAccessor,
|
||||
protected EventUndoHelper $eventUndoHelper)
|
||||
{
|
||||
//This option only makes sense if save_changed_data is true
|
||||
$this->save_new_data = $save_new_data && $save_changed_data;
|
||||
}
|
||||
|
||||
public function onFlush(OnFlushEventArgs $eventArgs): void
|
||||
|
|
@ -167,6 +170,7 @@ class EventLoggerListener
|
|||
public function hasFieldRestrictions(AbstractDBElement $element): bool
|
||||
{
|
||||
foreach (array_keys(static::FIELD_BLACKLIST) as $class) {
|
||||
/** @var string $class */
|
||||
if ($element instanceof $class) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -181,6 +185,7 @@ class EventLoggerListener
|
|||
public function shouldFieldBeSaved(AbstractDBElement $element, string $field_name): bool
|
||||
{
|
||||
foreach (static::FIELD_BLACKLIST as $class => $blacklist) {
|
||||
/** @var string $class */
|
||||
if ($element instanceof $class && in_array($field_name, $blacklist, true)) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -200,18 +205,19 @@ class EventLoggerListener
|
|||
if ($this->eventUndoHelper->isUndo()) {
|
||||
$log->setUndoneEvent($this->eventUndoHelper->getUndoneEvent(), $this->eventUndoHelper->getMode());
|
||||
}
|
||||
if ($this->save_removed_data) {
|
||||
if ($this->settings->saveRemovedData) {
|
||||
//The 4th param is important here, as we delete the element...
|
||||
$this->saveChangeSet($entity, $log, $em, true);
|
||||
}
|
||||
$this->logger->logFromOnFlush($log);
|
||||
|
||||
//Check if we have to log CollectionElementDeleted entries
|
||||
if ($this->save_changed_data) {
|
||||
if ($this->settings->saveOldData) {
|
||||
$metadata = $em->getClassMetadata($entity::class);
|
||||
$mappings = $metadata->getAssociationMappings();
|
||||
//Check if class is whitelisted for CollectionElementDeleted entry
|
||||
foreach (static::TRIGGER_ASSOCIATION_LOG_WHITELIST as $class => $whitelist) {
|
||||
/** @var string $class */
|
||||
if ($entity instanceof $class) {
|
||||
//Check names
|
||||
foreach ($mappings as $field => $mapping) {
|
||||
|
|
@ -243,9 +249,9 @@ class EventLoggerListener
|
|||
}
|
||||
|
||||
$log = new ElementEditedLogEntry($entity);
|
||||
if ($this->save_changed_data) {
|
||||
if ($this->settings->saveOldData) {
|
||||
$this->saveChangeSet($entity, $log, $em);
|
||||
} elseif ($this->save_changed_fields) {
|
||||
} elseif ($this->settings->saveChangedFields) {
|
||||
$changed_fields = array_keys($uow->getEntityChangeSet($entity));
|
||||
//Remove lastModified field, as this is always changed (gives us no additional info)
|
||||
$changed_fields = array_diff($changed_fields, ['lastModified']);
|
||||
|
|
@ -313,7 +319,7 @@ class EventLoggerListener
|
|||
$changeSet = $uow->getEntityChangeSet($entity);
|
||||
$old_data = array_combine(array_keys($changeSet), array_column($changeSet, 0));
|
||||
//If save_new_data is enabled, we extract it from the change set
|
||||
if ($this->save_new_data) {
|
||||
if ($this->settings->saveNewData && $this->settings->saveOldData) { //Only useful if we save old data too
|
||||
$new_data = array_combine(array_keys($changeSet), array_column($changeSet, 1));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2022 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\EventSubscriber;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
|
||||
/**
|
||||
* This subscriber sets a Header in Debug mode that signals the Symfony Profiler to also update on Ajax requests.
|
||||
*/
|
||||
final class SymfonyDebugToolbarSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private readonly bool $kernel_debug_enabled)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of event names this subscriber wants to listen to.
|
||||
*
|
||||
* The array keys are event names and the value can be:
|
||||
*
|
||||
* * The method name to call (priority defaults to 0)
|
||||
* * An array composed of the method name to call and the priority
|
||||
* * An array of arrays composed of the method names to call and respective
|
||||
* priorities, or 0 if unset
|
||||
*
|
||||
* For instance:
|
||||
*
|
||||
* * ['eventName' => 'methodName']
|
||||
* * ['eventName' => ['methodName', $priority]]
|
||||
* * ['eventName' => [['methodName1', $priority], ['methodName2']]]
|
||||
*
|
||||
* @return array The event names to listen to
|
||||
*/
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return ['kernel.response' => 'onKernelResponse'];
|
||||
}
|
||||
|
||||
public function onKernelResponse(ResponseEvent $event): void
|
||||
{
|
||||
if (!$this->kernel_debug_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $event->getResponse();
|
||||
$response->headers->set('Symfony-Debug-Toolbar-Replace', '1');
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
|||
namespace App\EventSubscriber\UserSystem;
|
||||
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\ControllerEvent;
|
||||
|
|
@ -33,7 +34,7 @@ use Symfony\Component\HttpKernel\KernelEvents;
|
|||
*/
|
||||
final class SetUserTimezoneSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private readonly string $default_timezone, private readonly Security $security)
|
||||
public function __construct(private readonly LocalizationSettings $localizationSettings, private readonly Security $security)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -48,8 +49,8 @@ final class SetUserTimezoneSubscriber implements EventSubscriberInterface
|
|||
}
|
||||
|
||||
//Fill with default value if needed
|
||||
if (null === $timezone && $this->default_timezone !== '') {
|
||||
$timezone = $this->default_timezone;
|
||||
if (null === $timezone && $this->localizationSettings->timezone !== '') {
|
||||
$timezone = $this->localizationSettings->timezone;
|
||||
}
|
||||
|
||||
//If timezone was configured anywhere set it, otherwise just use the one from php.ini
|
||||
|
|
|
|||
48
src/Exceptions/OAuthReconnectRequiredException.php
Normal file
48
src/Exceptions/OAuthReconnectRequiredException.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\Exceptions;
|
||||
|
||||
use Throwable;
|
||||
|
||||
class OAuthReconnectRequiredException extends \RuntimeException
|
||||
{
|
||||
private string $providerName = "unknown";
|
||||
|
||||
public function __construct(string $message = "You need to reconnect the OAuth connection for this provider!", int $code = 0, ?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
public static function forProvider(string $providerName): self
|
||||
{
|
||||
$exception = new self("You need to reconnect the OAuth connection for the provider '$providerName'!");
|
||||
$exception->providerName = $providerName;
|
||||
return $exception;
|
||||
}
|
||||
|
||||
public function getProviderName(): string
|
||||
{
|
||||
return $this->providerName;
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ namespace App\Form\AdminPages;
|
|||
use App\Entity\PriceInformations\Currency;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\UserSystem\Group;
|
||||
use App\Services\LogSystem\EventCommentType;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Base\AbstractNamedDBElement;
|
||||
use App\Entity\Base\AbstractStructuralDBElement;
|
||||
|
|
@ -152,7 +153,7 @@ class BaseEntityAdminForm extends AbstractType
|
|||
$builder->add('log_comment', TextType::class, [
|
||||
'label' => 'edit.log_comment',
|
||||
'mapped' => false,
|
||||
'required' => $this->eventCommentNeededHelper->isCommentNeeded($is_new ? 'datastructure_create': 'datastructure_edit'),
|
||||
'required' => $this->eventCommentNeededHelper->isCommentNeeded($is_new ? EventCommentType::DATASTRUCTURE_CREATE: EventCommentType::DATASTRUCTURE_EDIT),
|
||||
'empty_data' => null,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Form\AdminPages;
|
||||
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Base\AbstractNamedDBElement;
|
||||
use App\Form\Type\BigDecimalMoneyType;
|
||||
|
|
@ -32,7 +33,7 @@ use Symfony\Component\Form\FormBuilderInterface;
|
|||
|
||||
class CurrencyAdminForm extends BaseEntityAdminForm
|
||||
{
|
||||
public function __construct(Security $security, EventCommentNeededHelper $eventCommentNeededHelper, private readonly string $base_currency)
|
||||
public function __construct(Security $security, EventCommentNeededHelper $eventCommentNeededHelper, private readonly LocalizationSettings $localizationSettings)
|
||||
{
|
||||
parent::__construct($security, $eventCommentNeededHelper);
|
||||
}
|
||||
|
|
@ -51,7 +52,7 @@ class CurrencyAdminForm extends BaseEntityAdminForm
|
|||
$builder->add('exchange_rate', BigDecimalMoneyType::class, [
|
||||
'required' => false,
|
||||
'label' => 'currency.edit.exchange_rate',
|
||||
'currency' => $this->base_currency,
|
||||
'currency' => $this->localizationSettings->baseCurrency,
|
||||
'scale' => 6,
|
||||
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ class ImportType extends AbstractType
|
|||
'XML' => 'xml',
|
||||
'CSV' => 'csv',
|
||||
'YAML' => 'yaml',
|
||||
'XLSX' => 'xlsx',
|
||||
'XLS' => 'xls',
|
||||
],
|
||||
'label' => 'export.format',
|
||||
'disabled' => $disabled,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Form\AdminPages;
|
||||
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Base\AbstractNamedDBElement;
|
||||
use App\Entity\PriceInformations\Currency;
|
||||
|
|
@ -32,7 +33,7 @@ use Symfony\Component\Form\FormBuilderInterface;
|
|||
|
||||
class SupplierForm extends CompanyForm
|
||||
{
|
||||
public function __construct(Security $security, EventCommentNeededHelper $eventCommentNeededHelper, protected string $base_currency)
|
||||
public function __construct(Security $security, EventCommentNeededHelper $eventCommentNeededHelper, private readonly LocalizationSettings $localizationSettings)
|
||||
{
|
||||
parent::__construct($security, $eventCommentNeededHelper);
|
||||
}
|
||||
|
|
@ -53,7 +54,7 @@ class SupplierForm extends CompanyForm
|
|||
|
||||
$builder->add('shipping_costs', BigDecimalMoneyType::class, [
|
||||
'required' => false,
|
||||
'currency' => $this->base_currency,
|
||||
'currency' => $this->localizationSettings->baseCurrency,
|
||||
'scale' => 3,
|
||||
'label' => 'supplier.shipping_costs.label',
|
||||
'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity),
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Settings\SystemSettings\AttachmentsSettings;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
|
|
@ -54,9 +55,7 @@ class AttachmentFormType extends AbstractType
|
|||
protected Security $security,
|
||||
protected AttachmentSubmitHandler $submitHandler,
|
||||
protected TranslatorInterface $translator,
|
||||
protected bool $allow_attachments_download,
|
||||
protected bool $download_by_default,
|
||||
protected string $max_file_size
|
||||
protected AttachmentsSettings $settings,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +107,7 @@ class AttachmentFormType extends AbstractType
|
|||
'required' => false,
|
||||
'label' => 'attachment.edit.download_url',
|
||||
'mapped' => false,
|
||||
'disabled' => !$this->allow_attachments_download,
|
||||
'disabled' => !$this->settings->allowDownloads,
|
||||
]);
|
||||
|
||||
$builder->add('file', FileType::class, [
|
||||
|
|
@ -177,7 +176,7 @@ class AttachmentFormType extends AbstractType
|
|||
|
||||
//If the attachment should be downloaded by default (and is download allowed at all), register a listener,
|
||||
// which sets the downloadURL checkbox to true for new attachments
|
||||
if ($this->download_by_default && $this->allow_attachments_download) {
|
||||
if ($this->settings->downloadByDefault && $this->settings->allowDownloads) {
|
||||
$builder->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event): void {
|
||||
$form = $event->getForm();
|
||||
$attachment = $form->getData();
|
||||
|
|
@ -204,7 +203,7 @@ class AttachmentFormType extends AbstractType
|
|||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => Attachment::class,
|
||||
'max_file_size' => $this->max_file_size,
|
||||
'max_file_size' => $this->settings->maxFileSize,
|
||||
'allow_builtins' => true,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ declare(strict_types=1);
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Form;
|
||||
namespace App\Form\Extension;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
|
@ -1,4 +1,22 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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);
|
||||
|
||||
|
|
@ -20,7 +38,7 @@ declare(strict_types=1);
|
|||
* 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\Form;
|
||||
namespace App\Form\Extension;
|
||||
|
||||
use Symfony\Component\Form\AbstractTypeExtension;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
60
src/Form/Extension/SelectTypeOrderExtension.php
Normal file
60
src/Form/Extension/SelectTypeOrderExtension.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 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\Form\Extension;
|
||||
|
||||
use Symfony\Component\Form\AbstractTypeExtension;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class SelectTypeOrderExtension extends AbstractTypeExtension
|
||||
{
|
||||
public static function getExtendedTypes(): iterable
|
||||
{
|
||||
return [
|
||||
ChoiceType::class,
|
||||
EnumType::class
|
||||
];
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefault('ordered', false);
|
||||
$resolver->setDefault('by_reference', function (Options $options) {
|
||||
//Disable by_reference if the field is ordered (otherwise the order will be lost)
|
||||
return !$options['ordered'];
|
||||
});
|
||||
}
|
||||
|
||||
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
//Pass the data in ordered form to the frontend controller, so it can make the items appear in the correct order.
|
||||
if ($options['ordered']) {
|
||||
$view->vars['attr']['data-ordered-value'] = json_encode($form->getViewData(), JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
122
src/Form/Extension/TogglePasswordTypeExtension.php
Normal file
122
src/Form/Extension/TogglePasswordTypeExtension.php
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\Form\Extension;
|
||||
|
||||
use Symfony\Component\Form\AbstractTypeExtension;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Translation\TranslatableMessage;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
final class TogglePasswordTypeExtension extends AbstractTypeExtension
|
||||
{
|
||||
public function __construct(private readonly ?TranslatorInterface $translator)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getExtendedTypes(): iterable
|
||||
{
|
||||
return [PasswordType::class];
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'toggle' => false,
|
||||
'hidden_label' => 'Hide',
|
||||
'visible_label' => 'Show',
|
||||
'hidden_icon' => 'Default',
|
||||
'visible_icon' => 'Default',
|
||||
'button_classes' => ['toggle-password-button'],
|
||||
'toggle_container_classes' => ['toggle-password-container'],
|
||||
'toggle_translation_domain' => null,
|
||||
'use_toggle_form_theme' => true,
|
||||
]);
|
||||
|
||||
$resolver->setNormalizer(
|
||||
'toggle_translation_domain',
|
||||
static fn (Options $options, $labelTranslationDomain) => $labelTranslationDomain ?? $options['translation_domain'],
|
||||
);
|
||||
|
||||
$resolver->setAllowedTypes('toggle', ['bool']);
|
||||
$resolver->setAllowedTypes('hidden_label', ['string', TranslatableMessage::class, 'null']);
|
||||
$resolver->setAllowedTypes('visible_label', ['string', TranslatableMessage::class, 'null']);
|
||||
$resolver->setAllowedTypes('hidden_icon', ['string', 'null']);
|
||||
$resolver->setAllowedTypes('visible_icon', ['string', 'null']);
|
||||
$resolver->setAllowedTypes('button_classes', ['string[]']);
|
||||
$resolver->setAllowedTypes('toggle_container_classes', ['string[]']);
|
||||
$resolver->setAllowedTypes('toggle_translation_domain', ['string', 'bool', 'null']);
|
||||
$resolver->setAllowedTypes('use_toggle_form_theme', ['bool']);
|
||||
}
|
||||
|
||||
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
$view->vars['toggle'] = $options['toggle'];
|
||||
|
||||
if (!$options['toggle']) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($options['use_toggle_form_theme']) {
|
||||
array_splice($view->vars['block_prefixes'], -1, 0, 'toggle_password');
|
||||
}
|
||||
|
||||
$controllerName = 'toggle-password';
|
||||
$controllerValues = [];
|
||||
$view->vars['attr']['data-controller'] = trim(\sprintf('%s %s', $view->vars['attr']['data-controller'] ?? '', $controllerName));
|
||||
|
||||
if (false !== $options['toggle_translation_domain']) {
|
||||
$controllerValues['hidden-label'] = $this->translateLabel($options['hidden_label'], $options['toggle_translation_domain']);
|
||||
$controllerValues['visible-label'] = $this->translateLabel($options['visible_label'], $options['toggle_translation_domain']);
|
||||
} else {
|
||||
$controllerValues['hidden-label'] = $options['hidden_label'];
|
||||
$controllerValues['visible-label'] = $options['visible_label'];
|
||||
}
|
||||
|
||||
$controllerValues['hidden-icon'] = $options['hidden_icon'];
|
||||
$controllerValues['visible-icon'] = $options['visible_icon'];
|
||||
$controllerValues['button-classes'] = json_encode($options['button_classes'], \JSON_THROW_ON_ERROR);
|
||||
|
||||
foreach ($controllerValues as $name => $value) {
|
||||
$view->vars['attr'][\sprintf('data-%s-%s-value', $controllerName, $name)] = $value;
|
||||
}
|
||||
|
||||
$view->vars['toggle_container_classes'] = $options['toggle_container_classes'];
|
||||
}
|
||||
|
||||
private function translateLabel(string|TranslatableMessage|null $label, ?string $translationDomain): ?string
|
||||
{
|
||||
if (null === $this->translator || null === $label) {
|
||||
return $label;
|
||||
}
|
||||
|
||||
if ($label instanceof TranslatableMessage) {
|
||||
return $label->trans($this->translator);
|
||||
}
|
||||
|
||||
return $this->translator->trans($label, domain: $translationDomain);
|
||||
}
|
||||
}
|
||||
|
|
@ -100,7 +100,7 @@ class LogFilterType extends AbstractType
|
|||
]);
|
||||
|
||||
$builder->add('user', UserEntityConstraintType::class, [
|
||||
'label' => 'log.user',
|
||||
'label' => 'log.user',
|
||||
]);
|
||||
|
||||
$builder->add('targetType', EnumConstraintType::class, [
|
||||
|
|
@ -128,11 +128,13 @@ class LogFilterType extends AbstractType
|
|||
LogTargetType::PARAMETER => 'parameter.label',
|
||||
LogTargetType::LABEL_PROFILE => 'label_profile.label',
|
||||
LogTargetType::PART_ASSOCIATION => 'part_association.label',
|
||||
LogTargetType::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.label',
|
||||
LogTargetType::BULK_INFO_PROVIDER_IMPORT_JOB_PART => 'bulk_info_provider_import_job_part.label',
|
||||
},
|
||||
]);
|
||||
|
||||
$builder->add('targetId', NumberConstraintType::class, [
|
||||
'label' => 'log.target_id',
|
||||
'label' => 'log.target_id',
|
||||
'min' => 1,
|
||||
'step' => 1,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -22,9 +22,12 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace App\Form\Filters;
|
||||
|
||||
use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint;
|
||||
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
|
||||
use App\DataTables\Filters\PartFilter;
|
||||
use App\Entity\Attachments\AttachmentType;
|
||||
use App\Entity\InfoProviderSystem\BulkImportJobStatus;
|
||||
use App\Entity\InfoProviderSystem\BulkImportPartStatus;
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Manufacturer;
|
||||
|
|
@ -33,8 +36,12 @@ use App\Entity\Parts\StorageLocation;
|
|||
use App\Entity\Parts\Supplier;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Form\Filters\Constraints\BooleanConstraintType;
|
||||
use App\Form\Filters\Constraints\BulkImportJobExistsConstraintType;
|
||||
use App\Form\Filters\Constraints\BulkImportJobStatusConstraintType;
|
||||
use App\Form\Filters\Constraints\BulkImportPartStatusConstraintType;
|
||||
use App\Form\Filters\Constraints\ChoiceConstraintType;
|
||||
use App\Form\Filters\Constraints\DateTimeConstraintType;
|
||||
use App\Form\Filters\Constraints\EnumConstraintType;
|
||||
use App\Form\Filters\Constraints\NumberConstraintType;
|
||||
use App\Form\Filters\Constraints\ParameterConstraintType;
|
||||
use App\Form\Filters\Constraints\StructuralEntityConstraintType;
|
||||
|
|
@ -50,6 +57,8 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
|||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
class PartFilterType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly Security $security)
|
||||
|
|
@ -298,6 +307,31 @@ class PartFilterType extends AbstractType
|
|||
|
||||
}
|
||||
|
||||
/**************************************************************************
|
||||
* Bulk Import Job tab
|
||||
**************************************************************************/
|
||||
if ($this->security->isGranted('@info_providers.create_parts')) {
|
||||
$builder
|
||||
->add('inBulkImportJob', BooleanConstraintType::class, [
|
||||
'label' => 'part.filter.in_bulk_import_job',
|
||||
])
|
||||
->add('bulkImportJobStatus', EnumConstraintType::class, [
|
||||
'enum_class' => BulkImportJobStatus::class,
|
||||
'label' => 'part.filter.bulk_import_job_status',
|
||||
'choice_label' => function (BulkImportJobStatus $value) {
|
||||
return t('bulk_import.status.' . $value->value);
|
||||
},
|
||||
])
|
||||
->add('bulkImportPartStatus', EnumConstraintType::class, [
|
||||
'enum_class' => BulkImportPartStatus::class,
|
||||
'label' => 'part.filter.bulk_import_part_status',
|
||||
'choice_label' => function (BulkImportPartStatus $value) {
|
||||
return t('bulk_import.part_status.' . $value->value);
|
||||
},
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
$builder->add('submit', SubmitType::class, [
|
||||
'label' => 'filter.submit',
|
||||
|
|
|
|||
49
src/Form/History/EnforceEventCommentTypesType.php
Normal file
49
src/Form/History/EnforceEventCommentTypesType.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 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\Form\History;
|
||||
|
||||
use App\Services\LogSystem\EventCommentType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* The type for the "enforceComments" setting in the HistorySettings.
|
||||
*/
|
||||
class EnforceEventCommentTypesType extends AbstractType
|
||||
{
|
||||
public function getParent(): string
|
||||
{
|
||||
return EnumType::class;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'multiple' => true,
|
||||
'class' => EventCommentType::class,
|
||||
'empty_data' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
62
src/Form/InfoProviderSystem/BulkProviderSearchType.php
Normal file
62
src/Form/InfoProviderSystem/BulkProviderSearchType.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 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\Form\InfoProviderSystem;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class BulkProviderSearchType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$parts = $options['parts'];
|
||||
|
||||
$builder->add('part_configurations', CollectionType::class, [
|
||||
'entry_type' => PartProviderConfigurationType::class,
|
||||
'entry_options' => [
|
||||
'label' => false,
|
||||
],
|
||||
'allow_add' => false,
|
||||
'allow_delete' => false,
|
||||
'label' => false,
|
||||
]);
|
||||
|
||||
$builder->add('submit', SubmitType::class, [
|
||||
'label' => 'info_providers.bulk_search.submit'
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'parts' => [],
|
||||
]);
|
||||
$resolver->setRequired('parts');
|
||||
}
|
||||
}
|
||||
75
src/Form/InfoProviderSystem/FieldToProviderMappingType.php
Normal file
75
src/Form/InfoProviderSystem/FieldToProviderMappingType.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 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\Form\InfoProviderSystem;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class FieldToProviderMappingType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$fieldChoices = $options['field_choices'] ?? [];
|
||||
|
||||
$builder->add('field', ChoiceType::class, [
|
||||
'label' => 'info_providers.bulk_search.search_field',
|
||||
'choices' => $fieldChoices,
|
||||
'expanded' => false,
|
||||
'multiple' => false,
|
||||
'required' => false,
|
||||
'placeholder' => 'info_providers.bulk_search.field.select',
|
||||
]);
|
||||
|
||||
$builder->add('providers', ProviderSelectType::class, [
|
||||
'label' => 'info_providers.bulk_search.providers',
|
||||
'help' => 'info_providers.bulk_search.providers.help',
|
||||
'required' => false,
|
||||
]);
|
||||
|
||||
$builder->add('priority', IntegerType::class, [
|
||||
'label' => 'info_providers.bulk_search.priority',
|
||||
'help' => 'info_providers.bulk_search.priority.help',
|
||||
'required' => false,
|
||||
'data' => 1, // Default priority
|
||||
'attr' => [
|
||||
'min' => 1,
|
||||
'max' => 10,
|
||||
'class' => 'form-control-sm',
|
||||
'style' => 'width: 80px;'
|
||||
],
|
||||
'constraints' => [
|
||||
new \Symfony\Component\Validator\Constraints\Range(['min' => 1, 'max' => 10]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'field_choices' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
67
src/Form/InfoProviderSystem/GlobalFieldMappingType.php
Normal file
67
src/Form/InfoProviderSystem/GlobalFieldMappingType.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 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\Form\InfoProviderSystem;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class GlobalFieldMappingType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$fieldChoices = $options['field_choices'] ?? [];
|
||||
|
||||
$builder->add('field_mappings', CollectionType::class, [
|
||||
'entry_type' => FieldToProviderMappingType::class,
|
||||
'entry_options' => [
|
||||
'label' => false,
|
||||
'field_choices' => $fieldChoices,
|
||||
],
|
||||
'allow_add' => true,
|
||||
'allow_delete' => true,
|
||||
'prototype' => true,
|
||||
'label' => false,
|
||||
]);
|
||||
|
||||
$builder->add('prefetch_details', CheckboxType::class, [
|
||||
'label' => 'info_providers.bulk_import.prefetch_details',
|
||||
'required' => false,
|
||||
'help' => 'info_providers.bulk_import.prefetch_details_help',
|
||||
]);
|
||||
|
||||
$builder->add('submit', SubmitType::class, [
|
||||
'label' => 'info_providers.bulk_import.search.submit'
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'field_choices' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 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\Form\InfoProviderSystem;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
||||
class PartProviderConfigurationType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('part_id', HiddenType::class);
|
||||
|
||||
$builder->add('search_field', ChoiceType::class, [
|
||||
'label' => 'info_providers.bulk_search.search_field',
|
||||
'choices' => [
|
||||
'info_providers.bulk_search.field.mpn' => 'mpn',
|
||||
'info_providers.bulk_search.field.name' => 'name',
|
||||
'info_providers.bulk_search.field.digikey_spn' => 'digikey_spn',
|
||||
'info_providers.bulk_search.field.mouser_spn' => 'mouser_spn',
|
||||
'info_providers.bulk_search.field.lcsc_spn' => 'lcsc_spn',
|
||||
'info_providers.bulk_search.field.farnell_spn' => 'farnell_spn',
|
||||
],
|
||||
'expanded' => false,
|
||||
'multiple' => false,
|
||||
]);
|
||||
|
||||
$builder->add('providers', ProviderSelectType::class, [
|
||||
'label' => 'info_providers.bulk_search.providers',
|
||||
'help' => 'info_providers.bulk_search.providers.help',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ use App\Services\InfoProviderSystem\Providers\InfoProviderInterface;
|
|||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\ChoiceList\ChoiceList;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class ProviderSelectType extends AbstractType
|
||||
|
|
@ -44,13 +45,43 @@ class ProviderSelectType extends AbstractType
|
|||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'choices' => $this->providerRegistry->getActiveProviders(),
|
||||
'choice_label' => ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => $choice?->getProviderInfo()['name']),
|
||||
'choice_value' => ChoiceList::value($this, static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey()),
|
||||
$providers = $this->providerRegistry->getActiveProviders();
|
||||
|
||||
'multiple' => true,
|
||||
]);
|
||||
$resolver->setDefault('input', 'object');
|
||||
$resolver->setAllowedTypes('input', 'string');
|
||||
//Either the form returns the provider objects or their keys
|
||||
$resolver->setAllowedValues('input', ['object', 'string']);
|
||||
$resolver->setDefault('multiple', true);
|
||||
|
||||
$resolver->setDefault('choices', function (Options $options) use ($providers) {
|
||||
if ('object' === $options['input']) {
|
||||
return $this->providerRegistry->getActiveProviders();
|
||||
}
|
||||
|
||||
$tmp = [];
|
||||
foreach ($providers as $provider) {
|
||||
$name = $provider->getProviderInfo()['name'];
|
||||
$tmp[$name] = $provider->getProviderKey();
|
||||
}
|
||||
|
||||
return $tmp;
|
||||
});
|
||||
|
||||
//The choice_label and choice_value only needs to be set if we want the objects
|
||||
$resolver->setDefault('choice_label', function (Options $options){
|
||||
if ('object' === $options['input']) {
|
||||
return ChoiceList::label($this, static fn (?InfoProviderInterface $choice) => $choice?->getProviderInfo()['name']);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
$resolver->setDefault('choice_value', function (Options $options) {
|
||||
if ('object' === $options['input']) {
|
||||
return ChoiceList::value($this, static fn(?InfoProviderInterface $choice) => $choice?->getProviderKey());
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,16 @@ class LabelDialogType extends AbstractType
|
|||
]
|
||||
]);
|
||||
|
||||
if ($options['profile'] !== null) {
|
||||
$builder->add('update_profile', SubmitType::class, [
|
||||
'label' => 'label_generator.update_profile',
|
||||
'disabled' => !$this->security->isGranted('edit', $options['profile']),
|
||||
'attr' => [
|
||||
'class' => 'btn btn-outline-success'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
$builder->add('update', SubmitType::class, [
|
||||
'label' => 'label_generator.update',
|
||||
]);
|
||||
|
|
@ -97,5 +107,6 @@ class LabelDialogType extends AbstractType
|
|||
parent::configureOptions($resolver);
|
||||
$resolver->setDefault('mapped', false);
|
||||
$resolver->setDefault('disable_options', false);
|
||||
$resolver->setDefault('profile', null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ use App\Form\Type\SIUnitType;
|
|||
use App\Form\Type\StructuralEntityType;
|
||||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\LogSystem\EventCommentNeededHelper;
|
||||
use App\Services\LogSystem\EventCommentType;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
|
|
@ -265,7 +266,7 @@ class PartBaseType extends AbstractType
|
|||
$builder->add('log_comment', TextType::class, [
|
||||
'label' => 'edit.log_comment',
|
||||
'mapped' => false,
|
||||
'required' => $this->event_comment_needed_helper->isCommentNeeded($new_part ? 'part_create' : 'part_edit'),
|
||||
'required' => $this->event_comment_needed_helper->isCommentNeeded($new_part ? EventCommentType::PART_CREATE : EventCommentType::PART_EDIT),
|
||||
'empty_data' => null,
|
||||
]);
|
||||
|
||||
|
|
|
|||
81
src/Form/Type/APIKeyType.php
Normal file
81
src/Form/Type/APIKeyType.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2025 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\Form\Type;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class APIKeyType extends AbstractType
|
||||
{
|
||||
public function __construct(private readonly TranslatorInterface $translator)
|
||||
{
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return PasswordType::class;
|
||||
}
|
||||
|
||||
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
$viewData = $form->getViewData();
|
||||
|
||||
//If the field is disabled, show the redacted API key
|
||||
if ($options['disabled'] ?? false) {
|
||||
if ($viewData === null || $viewData === '') {
|
||||
$view->vars['value'] = $viewData;
|
||||
} else {
|
||||
|
||||
$view->vars['value'] = self::redact((string)$viewData) . ' (' . $this ->translator->trans("form.apikey.redacted") . ')';
|
||||
}
|
||||
} else { //Otherwise, show the actual value
|
||||
$view->vars['value'] = $viewData;
|
||||
}
|
||||
}
|
||||
|
||||
public static function redact(string $apiKey): string
|
||||
{
|
||||
//Show only the last 2 characters of the API key if it is long enough (more than 16 characters)
|
||||
//Replace all other characters with dots
|
||||
if (strlen($apiKey) > 16) {
|
||||
return str_repeat('*', strlen($apiKey) - 2) . substr($apiKey, -2);
|
||||
}
|
||||
|
||||
return str_repeat('*', strlen($apiKey));
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'always_empty' => false,
|
||||
'toggle' => true,
|
||||
'empty_data' => null,
|
||||
'attr' => ['autocomplete' => 'off'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ namespace App\Form\Type;
|
|||
use App\Entity\PriceInformations\Currency;
|
||||
use App\Form\Type\Helper\StructuralEntityChoiceHelper;
|
||||
use App\Services\Trees\NodesListBuilder;
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Intl\Currencies;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
|
|
@ -36,7 +37,7 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
|||
*/
|
||||
class CurrencyEntityType extends StructuralEntityType
|
||||
{
|
||||
public function __construct(EntityManagerInterface $em, NodesListBuilder $builder, TranslatorInterface $translator, StructuralEntityChoiceHelper $choiceHelper, protected ?string $base_currency)
|
||||
public function __construct(EntityManagerInterface $em, NodesListBuilder $builder, TranslatorInterface $translator, StructuralEntityChoiceHelper $choiceHelper, private readonly LocalizationSettings $localizationSettings)
|
||||
{
|
||||
parent::__construct($em, $builder, $translator, $choiceHelper);
|
||||
}
|
||||
|
|
@ -57,7 +58,7 @@ class CurrencyEntityType extends StructuralEntityType
|
|||
|
||||
$resolver->setDefault('empty_message', function (Options $options) {
|
||||
//By default, we use the global base currency:
|
||||
$iso_code = $this->base_currency;
|
||||
$iso_code = $this->localizationSettings->baseCurrency;
|
||||
|
||||
if ($options['base_currency']) { //Allow to override it
|
||||
$iso_code = $options['base_currency'];
|
||||
|
|
|
|||
57
src/Form/Type/LanguageMenuEntriesType.php
Normal file
57
src/Form/Type/LanguageMenuEntriesType.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 - 2025 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\Form\Type;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\LanguageType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\LocaleType;
|
||||
use Symfony\Component\Intl\Languages;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class LanguageMenuEntriesType extends AbstractType
|
||||
{
|
||||
public function __construct(#[Autowire(param: 'partdb.locale_menu')] private readonly array $preferred_languages)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return LanguageType::class;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$choices = [];
|
||||
foreach ($this->preferred_languages as $lang_code) {
|
||||
$choices[Languages::getName($lang_code)] = $lang_code;
|
||||
}
|
||||
|
||||
$resolver->setDefaults([
|
||||
'choice_loader' => null,
|
||||
'choices' => $choices,
|
||||
]);
|
||||
}
|
||||
}
|
||||
53
src/Form/Type/LocaleSelectType.php
Normal file
53
src/Form/Type/LocaleSelectType.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2024 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\Form\Type;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\LocaleType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* A locale select field that uses the preferred languages from the configuration.
|
||||
|
||||
*/
|
||||
class LocaleSelectType extends AbstractType
|
||||
{
|
||||
|
||||
public function __construct(#[Autowire(param: 'partdb.locale_menu')] private readonly array $preferred_languages)
|
||||
{
|
||||
|
||||
}
|
||||
public function getParent(): string
|
||||
{
|
||||
return LocaleType::class;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'preferred_choices' => $this->preferred_languages,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -100,7 +100,7 @@ final class TriStateCheckboxType extends AbstractType implements DataTransformer
|
|||
* @return mixed The value in the transformed representation
|
||||
*
|
||||
*/
|
||||
public function transform(mixed $value)
|
||||
public function transform(mixed $value): mixed
|
||||
{
|
||||
if (true === $value) {
|
||||
return 'true';
|
||||
|
|
@ -142,7 +142,7 @@ final class TriStateCheckboxType extends AbstractType implements DataTransformer
|
|||
*
|
||||
* @return mixed The value in the original representation
|
||||
*/
|
||||
public function reverseTransform(mixed $value)
|
||||
public function reverseTransform(mixed $value): mixed
|
||||
{
|
||||
return match ($value) {
|
||||
'true' => true,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Type\LocaleSelectType;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Base\AbstractNamedDBElement;
|
||||
use App\Entity\UserSystem\Group;
|
||||
|
|
@ -35,7 +36,6 @@ use App\Form\Type\ThemeChoiceType;
|
|||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\LanguageType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ResetType;
|
||||
|
|
@ -140,11 +140,10 @@ class UserAdminForm extends AbstractType
|
|||
])
|
||||
|
||||
//Config section
|
||||
->add('language', LanguageType::class, [
|
||||
->add('language', LocaleSelectType::class, [
|
||||
'required' => false,
|
||||
'placeholder' => 'user_settings.language.placeholder',
|
||||
'label' => 'user.language_select',
|
||||
'preferred_choices' => ['en', 'de'],
|
||||
'disabled' => !$this->security->isGranted('change_user_settings', $entity),
|
||||
])
|
||||
->add('timezone', TimezoneType::class, [
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Type\LocaleSelectType;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Form\Type\CurrencyEntityType;
|
||||
|
|
@ -33,7 +34,6 @@ use Symfony\Component\Form\Event\PreSetDataEvent;
|
|||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\LanguageType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ResetType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
|
|
@ -47,7 +47,7 @@ class UserSettingsType extends AbstractType
|
|||
{
|
||||
public function __construct(protected Security $security,
|
||||
protected bool $demo_mode,
|
||||
#[Autowire(param: 'partdb.locale_menu')] private readonly array $preferred_languages)
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -107,12 +107,11 @@ class UserSettingsType extends AbstractType
|
|||
'mode' => 'markdown-full',
|
||||
'disabled' => !$this->security->isGranted('edit_infos', $options['data']) || $this->demo_mode,
|
||||
])
|
||||
->add('language', LanguageType::class, [
|
||||
->add('language', LocaleSelectType::class, [
|
||||
'disabled' => $this->demo_mode,
|
||||
'required' => false,
|
||||
'placeholder' => 'user_settings.language.placeholder',
|
||||
'label' => 'user.language_select',
|
||||
'preferred_choices' => $this->preferred_languages,
|
||||
])
|
||||
->add('timezone', TimezoneType::class, [
|
||||
'disabled' => $this->demo_mode,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ namespace App\Migration;
|
|||
use App\Entity\UserSystem\PermissionData;
|
||||
use App\Security\Interfaces\HasPermissionsInterface;
|
||||
use App\Services\UserSystem\PermissionPresetsHelper;
|
||||
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
trait WithPermPresetsTrait
|
||||
{
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class AttachmentRepository extends DBElementRepository
|
|||
}
|
||||
|
||||
/**
|
||||
* Gets the count of all external attachments (attachments containing an external path).
|
||||
* Gets the count of all external attachments (attachments containing only an external path).
|
||||
*
|
||||
* @throws NoResultException
|
||||
* @throws NonUniqueResultException
|
||||
|
|
@ -75,8 +75,9 @@ class AttachmentRepository extends DBElementRepository
|
|||
{
|
||||
$qb = $this->createQueryBuilder('attachment');
|
||||
$qb->select('COUNT(attachment)')
|
||||
->andWhere('attaachment.internal_path IS NULL')
|
||||
->where('attachment.external_path IS NOT NULL');
|
||||
->where('attachment.external_path IS NOT NULL')
|
||||
->andWhere('attachment.internal_path IS NULL');
|
||||
|
||||
$query = $qb->getQuery();
|
||||
|
||||
return (int) $query->getSingleScalarResult();
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\TwoFactorProviderInterface
|
|||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
|
||||
use Webauthn\PublicKeyCredential;
|
||||
|
||||
/**
|
||||
* This class decorates the Webauthn TwoFactorProvider and adds additional logic which allows us to set a last used date
|
||||
|
|
@ -88,10 +89,12 @@ class WebauthnKeyLastUseTwoFactorProvider implements TwoFactorProviderInterface
|
|||
|
||||
private function getWebauthnKeyFromCode(string $authenticationCode): ?WebauthnKey
|
||||
{
|
||||
$publicKeyCredentialLoader = $this->webauthnProvider->getPublicKeyCredentialLoader();
|
||||
$serializer = $this->webauthnProvider->getWebauthnSerializer();
|
||||
|
||||
//Try to load the public key credential from the code
|
||||
$publicKeyCredential = $publicKeyCredentialLoader->load($authenticationCode);
|
||||
$publicKeyCredential = $serializer->deserialize($authenticationCode, PublicKeyCredential::class, 'json', [
|
||||
'json_decode_options' => JSON_THROW_ON_ERROR
|
||||
]);
|
||||
|
||||
//Find the credential source for the given credential id
|
||||
$publicKeyCredentialSource = $this->publicKeyCredentialSourceRepository->findOneByCredentialId($publicKeyCredential->rawId);
|
||||
|
|
@ -103,4 +106,4 @@ class WebauthnKeyLastUseTwoFactorProvider implements TwoFactorProviderInterface
|
|||
|
||||
return $publicKeyCredentialSource;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
|||
namespace App\Security;
|
||||
|
||||
use App\Entity\UserSystem\User;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AccountStatusException;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
|
||||
use Symfony\Component\Security\Core\User\UserCheckerInterface;
|
||||
|
|
@ -51,7 +52,7 @@ final class UserChecker implements UserCheckerInterface
|
|||
*
|
||||
* @throws AccountStatusException
|
||||
*/
|
||||
public function checkPostAuth(UserInterface $user): void
|
||||
public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void
|
||||
{
|
||||
if (!$user instanceof User) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ use App\Entity\Attachments\UserAttachment;
|
|||
use RuntimeException;
|
||||
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
use function in_array;
|
||||
|
|
@ -56,7 +57,7 @@ final class AttachmentVoter extends Voter
|
|||
{
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
|
||||
//This voter only works for attachments
|
||||
|
|
@ -65,7 +66,8 @@ final class AttachmentVoter extends Voter
|
|||
}
|
||||
|
||||
if ($attribute === 'show_private') {
|
||||
return $this->helper->isGranted($token, 'attachments', 'show_private');
|
||||
$vote?->addReason('User is not allowed to view private attachments.');
|
||||
return $this->helper->isGranted($token, 'attachments', 'show_private', $vote);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -111,7 +113,8 @@ final class AttachmentVoter extends Voter
|
|||
throw new RuntimeException('Encountered unknown Parameter type: ' . $subject);
|
||||
}
|
||||
|
||||
return $this->helper->isGranted($token, $param, $this->mapOperation($attribute));
|
||||
$vote?->addReason('User is not allowed to '.$this->mapOperation($attribute).' attachments of type '.$param.'.');
|
||||
return $this->helper->isGranted($token, $param, $this->mapOperation($attribute), $vote);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ use App\Entity\ProjectSystem\Project;
|
|||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
|
|
@ -46,7 +47,7 @@ class BOMEntryVoter extends Voter
|
|||
return $this->supportsAttribute($attribute) && is_a($subject, ProjectBOMEntry::class, true);
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
if (!is_a($subject, ProjectBOMEntry::class, true)) {
|
||||
return false;
|
||||
|
|
@ -87,4 +88,4 @@ class BOMEntryVoter extends Voter
|
|||
{
|
||||
return $subjectType === 'string' || is_a($subjectType, ProjectBOMEntry::class, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ namespace App\Security\Voter;
|
|||
use App\Entity\UserSystem\Group;
|
||||
use App\Services\UserSystem\VoterHelper;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
|
|
@ -43,9 +44,9 @@ final class GroupVoter extends Voter
|
|||
*
|
||||
* @param string $attribute
|
||||
*/
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
return $this->helper->isGranted($token, 'groups', $attribute);
|
||||
return $this->helper->isGranted($token, 'groups', $attribute, $vote);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ namespace App\Security\Voter;
|
|||
use App\Services\UserSystem\PermissionManager;
|
||||
use App\Services\UserSystem\VoterHelper;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
|
|
@ -41,7 +42,7 @@ final class HasAccessPermissionsVoter extends Voter
|
|||
{
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $this->helper->resolveUser($token);
|
||||
return $this->permissionManager->hasAnyPermissionSetToAllowInherited($user);
|
||||
|
|
@ -56,4 +57,4 @@ final class HasAccessPermissionsVoter extends Voter
|
|||
{
|
||||
return $attribute === self::ROLE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ namespace App\Security\Voter;
|
|||
use App\Entity\UserSystem\User;
|
||||
use App\Services\UserSystem\VoterHelper;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
|
|
@ -47,9 +48,16 @@ final class ImpersonateUserVoter extends Voter
|
|||
&& $subject instanceof UserInterface;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
return $this->helper->isGranted($token, 'users', 'impersonate');
|
||||
$result = $this->helper->isGranted($token, 'users', 'impersonate');
|
||||
|
||||
if ($result === false) {
|
||||
$vote?->addReason('User is not allowed to impersonate other users.');
|
||||
$this->helper->addReason($vote, 'users', 'impersonate');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function supportsAttribute(string $attribute): bool
|
||||
|
|
@ -61,4 +69,4 @@ final class ImpersonateUserVoter extends Voter
|
|||
{
|
||||
return is_a($subjectType, User::class, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ namespace App\Security\Voter;
|
|||
use App\Entity\LabelSystem\LabelProfile;
|
||||
use App\Services\UserSystem\VoterHelper;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
|
|
@ -58,14 +59,15 @@ final class LabelProfileVoter extends Voter
|
|||
'delete' => 'delete_profiles',
|
||||
'show_history' => 'show_history',
|
||||
'revert_element' => 'revert_element',
|
||||
'import' => 'import',
|
||||
];
|
||||
|
||||
public function __construct(private readonly VoterHelper $helper)
|
||||
{}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
return $this->helper->isGranted($token, 'labels', self::MAPPING[$attribute]);
|
||||
return $this->helper->isGranted($token, 'labels', self::MAPPING[$attribute], $vote);
|
||||
}
|
||||
|
||||
protected function supports($attribute, $subject): bool
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ use App\Services\UserSystem\VoterHelper;
|
|||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\LogSystem\AbstractLogEntry;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
|
|
@ -39,7 +40,7 @@ final class LogEntryVoter extends Voter
|
|||
{
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
$user = $this->helper->resolveUser($token);
|
||||
|
||||
|
|
@ -48,19 +49,19 @@ final class LogEntryVoter extends Voter
|
|||
}
|
||||
|
||||
if ('delete' === $attribute) {
|
||||
return $this->helper->isGranted($token, 'system', 'delete_logs');
|
||||
return $this->helper->isGranted($token, 'system', 'delete_logs', $vote);
|
||||
}
|
||||
|
||||
if ('read' === $attribute) {
|
||||
//Allow read of the users own log entries
|
||||
if (
|
||||
$subject->getUser() === $user
|
||||
&& $this->helper->isGranted($token, 'self', 'show_logs')
|
||||
&& $this->helper->isGranted($token, 'self', 'show_logs', $vote)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->helper->isGranted($token, 'system', 'show_logs');
|
||||
return $this->helper->isGranted($token, 'system', 'show_logs', $vote);
|
||||
}
|
||||
|
||||
if ('show_details' === $attribute) {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ use Symfony\Bundle\SecurityBundle\Security;
|
|||
use App\Entity\Parts\Part;
|
||||
use App\Entity\PriceInformations\Orderdetail;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
|
|
@ -59,7 +60,7 @@ final class OrderdetailVoter extends Voter
|
|||
|
||||
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
if (! is_a($subject, Orderdetail::class, true)) {
|
||||
throw new \RuntimeException('This voter can only handle Orderdetail objects!');
|
||||
|
|
@ -75,7 +76,7 @@ final class OrderdetailVoter extends Voter
|
|||
|
||||
//If we have no part associated use the generic part permission
|
||||
if (is_string($subject) || !$subject->getPart() instanceof Part) {
|
||||
return $this->helper->isGranted($token, 'parts', $operation);
|
||||
return $this->helper->isGranted($token, 'parts', $operation, $vote);
|
||||
}
|
||||
|
||||
//Otherwise vote on the part
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ use App\Entity\Parameters\StorageLocationParameter;
|
|||
use App\Entity\Parameters\SupplierParameter;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
|
|
@ -53,7 +54,7 @@ final class ParameterVoter extends Voter
|
|||
{
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
//return $this->resolver->inherit($user, 'attachments', $attribute) ?? false;
|
||||
|
||||
|
|
@ -108,7 +109,7 @@ final class ParameterVoter extends Voter
|
|||
throw new RuntimeException('Encountered unknown Parameter type: ' . (is_object($subject) ? $subject::class : $subject));
|
||||
}
|
||||
|
||||
return $this->helper->isGranted($token, $param, $attribute);
|
||||
return $this->helper->isGranted($token, $param, $attribute, $vote);
|
||||
}
|
||||
|
||||
protected function supports(string $attribute, $subject): bool
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ use App\Services\UserSystem\VoterHelper;
|
|||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Parts\Part;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
|
|
@ -61,7 +62,7 @@ final class PartAssociationVoter extends Voter
|
|||
|
||||
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token, ?Vote $vote = null): bool
|
||||
{
|
||||
if (!is_string($subject) && !$subject instanceof PartAssociation) {
|
||||
throw new \RuntimeException('Invalid subject type!');
|
||||
|
|
@ -77,7 +78,7 @@ final class PartAssociationVoter extends Voter
|
|||
|
||||
//If we have no part associated use the generic part permission
|
||||
if (is_string($subject) || !$subject->getOwner() instanceof Part) {
|
||||
return $this->helper->isGranted($token, 'parts', $operation);
|
||||
return $this->helper->isGranted($token, 'parts', $operation, $vote);
|
||||
}
|
||||
|
||||
//Otherwise vote on the part
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue