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:
Marc Kreidler 2025-12-07 10:46:42 +01:00
commit effe1828a3
498 changed files with 58785 additions and 19864 deletions

View file

@ -70,4 +70,4 @@ class PropertyMetadataFactory implements PropertyMetadataFactoryInterface
return $metadata;
}
}
}

View file

@ -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 = [];

View 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;
}
}

View 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;
}
}

View file

@ -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

View file

@ -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');

View file

@ -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

View file

@ -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

View file

@ -46,7 +46,7 @@ class UsersPermissionsCommand extends Command
{
$this->userRepository = $entityManager->getRepository(User::class);
parent::__construct(self::$defaultName);
parent::__construct();
}
protected function configure(): void

View file

@ -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()) {

View 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()
]
);
}
}
}

View file

@ -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
]);
}
}
}

View file

@ -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()

View file

@ -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()) {

View file

@ -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

View file

@ -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'));
}, [

View file

@ -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);
}

View file

@ -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();

View file

@ -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
{

View 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
]);
}
}

View file

@ -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'),

View 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();
}
}

View file

@ -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');

View file

@ -28,10 +28,7 @@ abstract class AbstractConstraint implements FilterInterface
{
use FilterTrait;
/**
* @var string
*/
protected string $identifier;
protected ?string $identifier;
/**

View file

@ -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;
}
/**

View file

@ -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() . ')');
}
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}
}

View file

@ -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();

View file

@ -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) {

View file

@ -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');

View file

@ -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

View file

@ -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 . '%');
}
}

View file

@ -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

View file

@ -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;
}

View file

@ -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 . ')';
}
}
}

View 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;
}
}

View 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;
}

View file

@ -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;
}
}

View file

@ -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)]

View file

@ -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
{

View file

@ -318,6 +318,7 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
return new ArrayCollection();
}
//@phpstan-ignore-next-line
return $this->children ?? new ArrayCollection();
}

View 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';
}

View 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';
}

View 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;
}
}

View 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;
}
}

View file

@ -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,
};
}

View file

@ -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

View file

@ -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;
}
}

View file

@ -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 = '';

View file

@ -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;
/**

View file

@ -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 = '';

View 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;
}

View file

@ -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 = '';

View file

@ -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,
);
}
}

View 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);
}
}
}

View file

@ -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));
}
}

View file

@ -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');
}
}

View file

@ -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

View 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;
}
}

View file

@ -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,
]);

View file

@ -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),
]);

View file

@ -59,6 +59,8 @@ class ImportType extends AbstractType
'XML' => 'xml',
'CSV' => 'csv',
'YAML' => 'yaml',
'XLSX' => 'xlsx',
'XLS' => 'xls',
],
'label' => 'export.format',
'disabled' => $disabled,

View file

@ -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),

View file

@ -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,
]);
}

View file

@ -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;

View file

@ -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;

View 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);
}
}
}

View 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);
}
}

View file

@ -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,
]);

View file

@ -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',

View 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' => [],
]);
}
}

View 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');
}
}

View 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' => [],
]);
}
}

View 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' => [],
]);
}
}

View file

@ -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',
]);
}
}

View file

@ -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;
});
}
}
}

View file

@ -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);
}
}

View file

@ -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,
]);

View 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'],
]);
}
}

View file

@ -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'];

View 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,
]);
}
}

View 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,
]);
}
}

View file

@ -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,

View file

@ -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, [

View file

@ -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,

View file

@ -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
{

View file

@ -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();

View file

@ -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;
}
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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);
}
}
}

View file

@ -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);
}
/**

View file

@ -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;
}
}
}

View file

@ -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);
}
}
}

View file

@ -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

View file

@ -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) {

View file

@ -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

View file

@ -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

View file

@ -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