Merge branch 'master' into settings-bundle

This commit is contained in:
Jan Böhmer 2025-06-15 18:39:49 +02:00
commit 442457f11b
131 changed files with 12759 additions and 6750 deletions

View file

@ -33,23 +33,24 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\ApiPlatform\DocumentedAPIProperty;
use App\ApiPlatform\DocumentedAPIProperties\DocumentedAPIProperty;
use App\ApiPlatform\Filter\EntityFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\ApiPlatform\HandleAttachmentsUploadsProcessor;
use App\Repository\AttachmentRepository;
use App\EntityListeners\AttachmentDeleteListener;
use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractNamedDBElement;
use App\EntityListeners\AttachmentDeleteListener;
use App\Repository\AttachmentRepository;
use App\Validator\Constraints\Selectable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use LogicException;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Serializer\Attribute\DiscriminatorMap;
use Symfony\Component\Validator\Constraints as Assert;
use function in_array;
use InvalidArgumentException;
use LogicException;
/**
* Class Attachment.
@ -78,11 +79,16 @@ use LogicException;
denormalizationContext: ['groups' => ['attachment:write', 'attachment:write:standalone', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
processor: HandleAttachmentsUploadsProcessor::class,
)]
#[DocumentedAPIProperty(schemaName: 'Attachment-Read', property: 'media_url', type: 'string', nullable: true,
description: 'The URL to the file, where the attachment file can be downloaded. This can be an internal or external URL.',
example: '/media/part/2/bc547-6508afa5a79c8.pdf')]
#[DocumentedAPIProperty(schemaName: 'Attachment-Read', property: 'thumbnail_url', type: 'string', nullable: true,
description: 'The URL to a thumbnail version of this file. This only exists for internal picture attachments.')]
//This property is added by the denormalizer in order to resolve the placeholder
#[DocumentedAPIProperty(
schemaName: 'Attachment-Read', property: 'internal_path', type: 'string', nullable: false,
description: 'The URL to the internally saved copy of the file, if one exists',
example: '/media/part/2/bc547-6508afa5a79c8.pdf'
)]
#[DocumentedAPIProperty(
schemaName: 'Attachment-Read', property: 'thumbnail_url', type: 'string', nullable: true,
description: 'The URL to a thumbnail version of this file. This only exists for internal picture attachments.'
)]
#[ApiFilter(LikeFilter::class, properties: ["name"])]
#[ApiFilter(EntityFilter::class, properties: ["attachment_type"])]
#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)]
@ -91,8 +97,8 @@ use LogicException;
#[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)]
abstract class Attachment extends AbstractNamedDBElement
{
private const ORM_DISCRIMINATOR_MAP = ['PartDB\Part' => PartAttachment::class, 'Part' => PartAttachment::class,
'PartDB\Device' => ProjectAttachment::class, 'Device' => ProjectAttachment::class, 'AttachmentType' => AttachmentTypeAttachment::class,
private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'Device' => ProjectAttachment::class,
'AttachmentType' => AttachmentTypeAttachment::class,
'Category' => CategoryAttachment::class, 'Footprint' => FootprintAttachment::class, 'Manufacturer' => ManufacturerAttachment::class,
'Currency' => CurrencyAttachment::class, 'Group' => GroupAttachment::class, 'MeasurementUnit' => MeasurementUnitAttachment::class,
'Storelocation' => StorageLocationAttachment::class, 'Supplier' => SupplierAttachment::class,
@ -119,10 +125,6 @@ abstract class Attachment extends AbstractNamedDBElement
*/
final public const MODEL_EXTS = ['x3d'];
/**
* When the path begins with one of the placeholders.
*/
final public const INTERNAL_PLACEHOLDER = ['%BASE%', '%MEDIA%', '%SECURE%'];
/**
* @var array placeholders for attachments which using built in files
@ -152,10 +154,21 @@ abstract class Attachment extends AbstractNamedDBElement
protected ?string $original_filename = null;
/**
* @var string The path to the file relative to a placeholder path like %MEDIA%
* @var string|null If a copy of the file is stored internally, the path to the file relative to a placeholder
* path like %MEDIA%
*/
#[ORM\Column(name: 'path', type: Types::STRING)]
protected string $path = '';
#[ORM\Column(type: Types::STRING, nullable: true)]
protected ?string $internal_path = null;
/**
* @var string|null The path to the external source if the file is stored externally or was downloaded from an
* external source. Null if there is no external source.
*/
#[ORM\Column(type: Types::STRING, nullable: true)]
#[Groups(['attachment:read'])]
#[ApiProperty(example: 'http://example.com/image.jpg')]
protected ?string $external_path = null;
/**
* @var string the name of this element
@ -237,7 +250,7 @@ abstract class Attachment extends AbstractNamedDBElement
/**
* Check if this attachment is a picture (analyse the file's extension).
* If the link is external, it is assumed that this is true.
* If the link is only external and doesn't contain an extension, it is assumed that this is true.
*
* @return bool * true if the file extension is a picture extension
* * otherwise false
@ -245,54 +258,67 @@ abstract class Attachment extends AbstractNamedDBElement
#[Groups(['attachment:read'])]
public function isPicture(): bool
{
if ($this->isExternal()) {
if($this->hasInternal()){
$extension = pathinfo($this->getInternalPath(), PATHINFO_EXTENSION);
return in_array(strtolower($extension), static::PICTURE_EXTS, true);
}
if ($this->hasExternal()) {
//Check if we can extract a file extension from the URL
$extension = pathinfo(parse_url($this->path, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
$extension = pathinfo(parse_url($this->getExternalPath(), PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
//If no extension is found or it is known picture extension, we assume that this is a picture extension
return $extension === '' || in_array(strtolower($extension), static::PICTURE_EXTS, true);
}
$extension = pathinfo($this->getPath(), PATHINFO_EXTENSION);
return in_array(strtolower($extension), static::PICTURE_EXTS, true);
//File doesn't have an internal, nor an external copy. This shouldn't happen, but it certainly isn't a picture...
return false;
}
/**
* Check if this attachment is a 3D model and therefore can be directly shown to user.
* If the attachment is external, false is returned (3D Models must be internal).
* If no internal copy exists, false is returned (3D Models must be internal).
*/
#[Groups(['attachment:read'])]
#[SerializedName('3d_model')]
public function is3DModel(): bool
{
//We just assume that 3D Models are internally saved, otherwise we get problems loading them.
if ($this->isExternal()) {
if (!$this->hasInternal()) {
return false;
}
$extension = pathinfo($this->getPath(), PATHINFO_EXTENSION);
$extension = pathinfo($this->getInternalPath(), PATHINFO_EXTENSION);
return in_array(strtolower($extension), static::MODEL_EXTS, true);
}
/**
* Checks if the attachment file is externally saved (the database saves an URL).
* Checks if this attachment has a path to an external file
*
* @return bool true, if the file is saved externally
* @return bool true, if there is a path to an external file
* @phpstan-assert-if-true non-empty-string $this->external_path
* @phpstan-assert-if-true non-empty-string $this->getExternalPath())
*/
#[Groups(['attachment:read'])]
public function isExternal(): bool
public function hasExternal(): bool
{
//When path is empty, this attachment can not be external
if ($this->path === '') {
return false;
}
return $this->external_path !== null && $this->external_path !== '';
}
//After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode
$tmp = explode('/', $this->path);
return !in_array($tmp[0], array_merge(static::INTERNAL_PLACEHOLDER, static::BUILTIN_PLACEHOLDER), true);
/**
* Checks if this attachment has a path to an internal file.
* Does not check if the file exists.
*
* @return bool true, if there is a path to an internal file
* @phpstan-assert-if-true non-empty-string $this->internal_path
* @phpstan-assert-if-true non-empty-string $this->getInternalPath())
*/
#[Groups(['attachment:read'])]
public function hasInternal(): bool
{
return $this->internal_path !== null && $this->internal_path !== '';
}
/**
@ -305,8 +331,12 @@ abstract class Attachment extends AbstractNamedDBElement
#[SerializedName('private')]
public function isSecure(): bool
{
if ($this->internal_path === null) {
return false;
}
//After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode
$tmp = explode('/', $this->path);
$tmp = explode('/', $this->internal_path);
return '%SECURE%' === $tmp[0];
}
@ -320,7 +350,11 @@ abstract class Attachment extends AbstractNamedDBElement
#[Groups(['attachment:read'])]
public function isBuiltIn(): bool
{
return static::checkIfBuiltin($this->path);
if ($this->internal_path === null) {
return false;
}
return static::checkIfBuiltin($this->internal_path);
}
/********************************************************************************
@ -332,13 +366,13 @@ abstract class Attachment extends AbstractNamedDBElement
/**
* Returns the extension of the file referenced via the attachment.
* For a path like %BASE/path/foo.bar, bar will be returned.
* If this attachment is external null is returned.
* If this attachment is only external null is returned.
*
* @return string|null the file extension in lower case
*/
public function getExtension(): ?string
{
if ($this->isExternal()) {
if (!$this->hasInternal()) {
return null;
}
@ -346,7 +380,7 @@ abstract class Attachment extends AbstractNamedDBElement
return strtolower(pathinfo($this->original_filename, PATHINFO_EXTENSION));
}
return strtolower(pathinfo($this->getPath(), PATHINFO_EXTENSION));
return strtolower(pathinfo($this->getInternalPath(), PATHINFO_EXTENSION));
}
/**
@ -361,52 +395,54 @@ abstract class Attachment extends AbstractNamedDBElement
}
/**
* The URL to the external file, or the path to the built-in file.
* The URL to the external file, or the path to the built-in file, but not paths to uploaded files.
* Returns null, if the file is not external (and not builtin).
* The output of this function is such, that no changes occur when it is fed back into setURL().
* Required for the Attachment form field.
*/
#[Groups(['attachment:read'])]
#[SerializedName('url')]
public function getURL(): ?string
{
if (!$this->isExternal() && !$this->isBuiltIn()) {
return null;
if($this->hasExternal()){
return $this->getExternalPath();
}
return $this->path;
if($this->isBuiltIn()){
return $this->getInternalPath();
}
return null;
}
/**
* Returns the hostname where the external file is stored.
* Returns null, if the file is not external.
* Returns null, if there is no external path.
*/
public function getHost(): ?string
{
if (!$this->isExternal()) {
if (!$this->hasExternal()) {
return null;
}
return parse_url((string) $this->getURL(), PHP_URL_HOST);
return parse_url($this->getExternalPath(), PHP_URL_HOST);
}
/**
* Get the filepath, relative to %BASE%.
*
* @return string A string like %BASE/path/foo.bar
*/
public function getPath(): string
public function getInternalPath(): ?string
{
return $this->path;
return $this->internal_path;
}
public function getExternalPath(): ?string
{
return $this->external_path;
}
/**
* Returns the filename of the attachment.
* For a path like %BASE/path/foo.bar, foo.bar will be returned.
*
* If the path is a URL (can be checked via isExternal()), null will be returned.
* If there is no internal copy of the file, null will be returned.
*/
public function getFilename(): ?string
{
if ($this->isExternal()) {
if (!$this->hasInternal()) {
return null;
}
@ -415,7 +451,7 @@ abstract class Attachment extends AbstractNamedDBElement
return $this->original_filename;
}
return pathinfo($this->getPath(), PATHINFO_BASENAME);
return pathinfo($this->getInternalPath(), PATHINFO_BASENAME);
}
/**
@ -488,15 +524,12 @@ abstract class Attachment extends AbstractNamedDBElement
}
/**
* Sets the filepath (with relative placeholder) for this attachment.
*
* @param string $path the new filepath of the attachment
*
* @return Attachment
* Sets the path to a file hosted internally. If you set this path to a file that was not downloaded from the
* external source in external_path, make sure to reset external_path.
*/
public function setPath(string $path): self
public function setInternalPath(?string $internal_path): self
{
$this->path = $path;
$this->internal_path = $internal_path;
return $this;
}
@ -512,34 +545,60 @@ abstract class Attachment extends AbstractNamedDBElement
}
/**
* Sets the url associated with this attachment.
* If the url is empty nothing is changed, to not override the file path.
*
* @return Attachment
* Sets up the paths using a user provided string which might contain an external path or a builtin path. Allows
* resetting the external path if an internal path exists. Resets any other paths if a (nonempty) new path is set.
*/
#[Groups(['attachment:write'])]
#[SerializedName('url')]
#[ApiProperty(description: 'Set the path of the attachment here.
Provide either an external URL, a path to a builtin file (like %FOOTPRINTS%/Active/ICs/IC_DFS.png) or an empty
string if the attachment has an internal file associated and you\'d like to reset the external source.
If you set a new (nonempty) file path any associated internal file will be removed!')]
public function setURL(?string $url): self
{
//Do nothing if the URL is empty
if ($url === null || $url === '') {
//Don't allow the user to set an empty external path if the internal path is empty already
if (($url === null || $url === "") && !$this->hasInternal()) {
return $this;
}
$url = trim($url);
//Escape spaces in URL
$url = str_replace(' ', '%20', $url);
//Only set if the URL is not empty
if ($url !== '') {
if (str_contains($url, '%BASE%') || str_contains($url, '%MEDIA%')) {
throw new InvalidArgumentException('You can not reference internal files via the url field! But nice try!');
}
$this->path = $url;
//Reset internal filename
$this->original_filename = null;
//The URL field can also contain the special builtin internal paths, so we need to distinguish here
if ($this::checkIfBuiltin($url)) {
$this->setInternalPath($url);
//make sure the external path isn't still pointing to something unrelated
$this->setExternalPath(null);
} else {
$this->setExternalPath($url);
}
return $this;
}
/**
* Sets the path to a file hosted on an external server. Setting the external path to a (nonempty) value different
* from the the old one _clears_ the internal path, so that the external path reflects where any associated internal
* file came from.
*/
public function setExternalPath(?string $external_path): self
{
//If we only clear the external path, don't reset the internal path, since that could be confusing
if($external_path === null || $external_path === '') {
$this->external_path = null;
return $this;
}
$external_path = trim($external_path);
//Escape spaces in URL
$external_path = str_replace(' ', '%20', $external_path);
if($this->external_path === $external_path) {
//Nothing changed, nothing to do
return $this;
}
$this->external_path = $external_path;
$this->internal_path = null;
//Reset internal filename
$this->original_filename = null;
return $this;
}
@ -551,12 +610,17 @@ abstract class Attachment extends AbstractNamedDBElement
/**
* Checks if the given path is a path to a builtin resource.
*
* @param string $path The path that should be checked
* @param string|null $path The path that should be checked
*
* @return bool true if the path is pointing to a builtin resource
*/
public static function checkIfBuiltin(string $path): bool
public static function checkIfBuiltin(?string $path): bool
{
//An empty path can't be a builtin
if ($path === null) {
return false;
}
//After the %PLACEHOLDER% comes a slash, so we can check if we have a placeholder via explode
$tmp = explode('/', $path);
//Builtins must have a %PLACEHOLDER% construction

View file

@ -162,7 +162,7 @@ abstract class AbstractCompany extends AbstractPartsContainingDBElement
*
* @return string the link to the article
*/
public function getAutoProductUrl(string $partnr = null): string
public function getAutoProductUrl(?string $partnr = null): string
{
if (is_string($partnr)) {
return str_replace('%PARTNUMBER%', $partnr, $this->auto_product_url);

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

@ -54,7 +54,7 @@ class OAuthToken extends AbstractNamedDBElement implements AccessTokenInterface
*/
private const DEFAULT_EXPIRATION_TIME = 3600;
public function __construct(string $name, ?string $refresh_token, ?string $token = null, \DateTimeImmutable $expires_at = null)
public function __construct(string $name, ?string $refresh_token, ?string $token = null, ?\DateTimeImmutable $expires_at = null)
{
//If token is given, you also have to give the expires_at date
if ($token !== null && $expires_at === null) {

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) {
@ -218,7 +218,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
$str = '';
$bracket_opened = false;
if ($this->value_typical) {
$str .= $this->getValueTypicalWithUnit();
$str .= $this->getValueTypicalWithUnit($latex_formatted);
if ($this->value_min || $this->value_max) {
$bracket_opened = true;
$str .= ' (';
@ -226,11 +226,11 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
}
if ($this->value_max && $this->value_min) {
$str .= $this->getValueMinWithUnit().' ... '.$this->getValueMaxWithUnit();
$str .= $this->getValueMinWithUnit($latex_formatted).' ... '.$this->getValueMaxWithUnit($latex_formatted);
} elseif ($this->value_max) {
$str .= 'max. '.$this->getValueMaxWithUnit();
$str .= 'max. '.$this->getValueMaxWithUnit($latex_formatted);
} elseif ($this->value_min) {
$str .= 'min. '.$this->getValueMinWithUnit();
$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,11 +441,18 @@ 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 {
$unit = '$\mathrm{'.$this->unit.'}$';
}
return $str.' '.$unit;
}
return $str;