Merge tag 'v2.2.1' into order-details

This commit is contained in:
Fabian Wunsch 2025-11-11 14:51:52 +01:00
commit a64dec2985
90 changed files with 16636 additions and 1723 deletions

View file

@ -25,6 +25,7 @@ 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;
@ -175,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

View file

@ -36,6 +36,7 @@ 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;
@ -56,11 +57,21 @@ class PartListsController extends AbstractController
private readonly NodesListBuilder $nodesListBuilder,
private readonly DataTableFactory $dataTableFactory,
private readonly TranslatorInterface $translator,
private readonly TableSettings $tableSettings
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
{
@ -203,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'));
}, [
@ -221,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'));
}, [
@ -239,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'));
}, [
@ -257,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'));
}, [
@ -275,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

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

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

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

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

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

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

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

@ -38,6 +38,11 @@ class SIFormatter
*/
public function getMagnitude(float $value): int
{
//Check for zero, as log10(0) is undefined/gives -infinity, which leads to casting issues in PHP8.5+
if (0.0 === $value) {
return 0;
}
return (int) floor(log10(abs($value)));
}

View file

@ -221,7 +221,7 @@ final class DTOtoEntityConverter
$attachment = $this->convertFile($image, $image_type);
$attachments_grouped[$attachment->getName()][] = $attachment;
if (count($attachments_grouped[$attachment->getName()] ?? []) > 1) {
if (count($attachments_grouped[$attachment->getName()]) > 1) {
$attachment->setName($attachment->getName() . ' (' . (count($attachments_grouped[$attachment->getName()]) + 1) . ')');
}
@ -236,7 +236,7 @@ final class DTOtoEntityConverter
$attachment = $this->convertFile($datasheet, $datasheet_type);
$attachments_grouped[$attachment->getName()][] = $attachment;
if (count($attachments_grouped[$attachment->getName()] ?? []) > 1) {
if (count($attachments_grouped[$attachment->getName()]) > 1) {
$attachment->setName($attachment->getName() . ' (' . (count($attachments_grouped[$attachment->getName()])) . ')');
}
@ -357,4 +357,4 @@ final class DTOtoEntityConverter
return $tmp;
}
}
}

View file

@ -24,6 +24,7 @@ declare(strict_types=1);
namespace App\Services\InfoProviderSystem\Providers;
use App\Entity\Parts\ManufacturingStatus;
use App\Exceptions\OAuthReconnectRequiredException;
use App\Services\InfoProviderSystem\DTOs\FileDTO;
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
@ -117,12 +118,22 @@ class DigikeyProvider implements InfoProviderInterface
];
//$response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [
$response = $this->digikeyClient->request('POST', '/products/v4/search/keyword', [
'json' => $request,
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
]);
try {
$response = $this->digikeyClient->request('POST', '/products/v4/search/keyword', [
'json' => $request,
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
]);
$response_array = $response->toArray();
} catch (\InvalidArgumentException $exception) {
//Check if the exception was caused by an invalid or expired token
if (str_contains($exception->getMessage(), 'access_token')) {
throw OAuthReconnectRequiredException::forProvider($this->getProviderKey());
}
throw $exception;
}
$response_array = $response->toArray();
$result = [];
@ -150,9 +161,18 @@ class DigikeyProvider implements InfoProviderInterface
public function getDetails(string $id): PartDetailDTO
{
$response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/productdetails', [
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
]);
try {
$response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/productdetails', [
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
]);
} catch (\InvalidArgumentException $exception) {
//Check if the exception was caused by an invalid or expired token
if (str_contains($exception->getMessage(), 'access_token')) {
throw OAuthReconnectRequiredException::forProvider($this->getProviderKey());
}
throw $exception;
}
$response_array = $response->toArray();
$product = $response_array['Product'];

View file

@ -31,9 +31,9 @@ use App\Services\Parts\PartLotWithdrawAddHelper;
/**
* @see \App\Tests\Services\ProjectSystem\ProjectBuildHelperTest
*/
class ProjectBuildHelper
final readonly class ProjectBuildHelper
{
public function __construct(private readonly PartLotWithdrawAddHelper $withdraw_add_helper)
public function __construct(private PartLotWithdrawAddHelper $withdraw_add_helper)
{
}
@ -63,20 +63,37 @@ class ProjectBuildHelper
*/
public function getMaximumBuildableCount(Project $project): int
{
$bom_entries = $project->getBomEntries();
if ($bom_entries->isEmpty()) {
return 0;
}
$maximum_buildable_count = PHP_INT_MAX;
foreach ($project->getBomEntries() as $bom_entry) {
foreach ($bom_entries as $bom_entry) {
//Skip BOM entries without a part (as we can not determine that)
if (!$bom_entry->isPartBomEntry()) {
continue;
}
//The maximum buildable count for the whole project is the minimum of all BOM entries
$maximum_buildable_count = min($maximum_buildable_count, $this->getMaximumBuildableCountForBOMEntry($bom_entry));
}
return $maximum_buildable_count;
}
/**
* Returns the maximum buildable amount of the given project as string, based on the stock of the used parts in the BOM.
* If the maximum buildable count is infinite, the string '∞' is returned.
* @param Project $project
* @return string
*/
public function getMaximumBuildableCountAsString(Project $project): string
{
$max_count = $this->getMaximumBuildableCount($project);
if ($max_count === PHP_INT_MAX) {
return '∞';
}
return (string) $max_count;
}
/**
* Checks if the given project can be built with the current stock.
* This means that the maximum buildable count is greater or equal than the requested $number_of_projects

View file

@ -26,7 +26,7 @@ namespace App\Settings;
use App\Settings\BehaviorSettings\BehaviorSettings;
use App\Settings\InfoProviderSystem\InfoProviderSettings;
use App\Settings\MiscSettings\MiscSettings;
use App\Settings\SystemSettings\AttachmentsSettings;
use App\Settings\SystemSettings\SystemSettings;
use Jbtronics\SettingsBundle\Settings\EmbeddedSettings;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
@ -49,4 +49,4 @@ class AppSettings
#[EmbeddedSettings()]
public ?MiscSettings $miscSettings = null;
}
}

View file

@ -26,8 +26,9 @@ namespace App\Settings\BehaviorSettings;
use Jbtronics\SettingsBundle\Settings\EmbeddedSettings;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
use Symfony\Component\Translation\TranslatableMessage as TM;
#[Settings]
#[Settings(label: new TM("settings.behavior"))]
class BehaviorSettings
{
use SettingsTrait;
@ -40,4 +41,4 @@ class BehaviorSettings
#[EmbeddedSettings]
public ?PartInfoSettings $partInfo = null;
}
}

View file

@ -73,4 +73,11 @@ class SidebarSettings
*/
#[SettingsParameter(label: new TM("settings.behavior.sidebar.rootNodeRedirectsToNewEntity"))]
public bool $rootNodeRedirectsToNewEntity = false;
}
/**
* @var bool Whether to include child nodes in the data structure nodes table, or only show the selected node's parts.
*/
#[SettingsParameter(label: new TM("settings.behavior.sidebar.data_structure_nodes_table_include_children"),
description: new TM("settings.behavior.sidebar.data_structure_nodes_table_include_children.help"))]
public bool $dataStructureNodesTableIncludeChildren = true;
}

View file

@ -27,8 +27,9 @@ use Jbtronics\SettingsBundle\Settings\EmbeddedSettings;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
use Symfony\Component\Translation\TranslatableMessage as TM;
#[Settings()]
#[Settings(label: new TM("settings.ips"))]
class InfoProviderSettings
{
use SettingsTrait;

View file

@ -25,8 +25,9 @@ namespace App\Settings\MiscSettings;
use Jbtronics\SettingsBundle\Settings\EmbeddedSettings;
use Jbtronics\SettingsBundle\Settings\Settings;
use Symfony\Component\Translation\TranslatableMessage as TM;
#[Settings]
#[Settings(label: new TM("settings.misc"))]
class MiscSettings
{
#[EmbeddedSettings]
@ -34,4 +35,4 @@ class MiscSettings
#[EmbeddedSettings]
public ?ExchangeRateSettings $exchangeRate = null;
}
}

View file

@ -23,9 +23,12 @@ declare(strict_types=1);
namespace App\Settings\SystemSettings;
use App\Form\Type\LanguageMenuEntriesType;
use App\Form\Type\LocaleSelectType;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
use Jbtronics\SettingsBundle\ParameterTypes\ArrayType;
use Jbtronics\SettingsBundle\ParameterTypes\StringType;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
@ -60,4 +63,14 @@ class LocalizationSettings
envVar: "string:BASE_CURRENCY", envVarMode: EnvVarMode::OVERWRITE
)]
public string $baseCurrency = 'EUR';
}
#[SettingsParameter(type: ArrayType::class,
label: new TM("settings.system.localization.language_menu_entries"),
description: new TM("settings.system.localization.language_menu_entries.description"),
options: ['type' => StringType::class],
formType: LanguageMenuEntriesType::class,
formOptions: ['multiple' => true, 'required' => false, 'ordered' => true],
)]
#[Assert\All([new Assert\Locale()])]
public array $languageMenuEntries = [];
}

View file

@ -21,17 +21,13 @@
declare(strict_types=1);
namespace App\Settings;
namespace App\Settings\SystemSettings;
use App\Settings\SystemSettings\AttachmentsSettings;
use App\Settings\SystemSettings\CustomizationSettings;
use App\Settings\SystemSettings\HistorySettings;
use App\Settings\SystemSettings\LocalizationSettings;
use App\Settings\SystemSettings\PrivacySettings;
use Jbtronics\SettingsBundle\Settings\EmbeddedSettings;
use Jbtronics\SettingsBundle\Settings\Settings;
use Symfony\Component\Translation\TranslatableMessage as TM;
#[Settings]
#[Settings(label: new TM("settings.system"))]
class SystemSettings
{
#[EmbeddedSettings()]
@ -48,4 +44,4 @@ class SystemSettings
#[EmbeddedSettings()]
public ?HistorySettings $history = null;
}
}

View file

@ -82,7 +82,7 @@ final class FormatExtension extends AbstractExtension
public function formatBytes(int $bytes, int $precision = 2): string
{
$size = ['B','kB','MB','GB','TB','PB','EB','ZB','YB'];
$factor = floor((strlen((string) $bytes) - 1) / 3);
$factor = (int) floor((strlen((string) $bytes) - 1) / 3);
//We use the real (10 based) SI prefix here
return sprintf("%.{$precision}f", $bytes / (1000 ** $factor)) . ' ' . @$size[$factor];
}