diff --git a/migrations/Version20240322155533.php b/migrations/Version20240322155533.php new file mode 100644 index 0000000..690fe97 --- /dev/null +++ b/migrations/Version20240322155533.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE document_object ADD created_by_id INT NOT NULL, ADD name VARCHAR(255) NOT NULL, ADD description LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE document_object ADD CONSTRAINT FK_16CF1A8AB03A8386 FOREIGN KEY (created_by_id) REFERENCES `user` (id)'); + $this->addSql('CREATE INDEX IDX_16CF1A8AB03A8386 ON document_object (created_by_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE document_object DROP FOREIGN KEY FK_16CF1A8AB03A8386'); + $this->addSql('DROP INDEX IDX_16CF1A8AB03A8386 ON document_object'); + $this->addSql('ALTER TABLE document_object DROP created_by_id, DROP name, DROP description'); + } +} diff --git a/src/ApiResource/UserApi.php b/src/ApiResource/UserApi.php index da0dec9..948c123 100644 --- a/src/ApiResource/UserApi.php +++ b/src/ApiResource/UserApi.php @@ -46,7 +46,7 @@ use Symfony\Component\Validator\Constraints as Assert; stateOptions: new Options(entityClass: User::class), )] -#[ApiFilter(SearchFilter::class, properties: ['firstName' => 'partial', 'lastName' => 'partial'])] +#[ApiFilter(SearchFilter::class, properties: ['firstName' => 'ipartial', 'lastName' => 'ipartial'])] class UserApi { #[ApiProperty(readable: false, writable: false, identifier: true)] diff --git a/src/DataFixtures/FakeValues.php b/src/DataFixtures/FakeValues.php index c0ec168..10602bc 100644 --- a/src/DataFixtures/FakeValues.php +++ b/src/DataFixtures/FakeValues.php @@ -221,4 +221,17 @@ class FakeValues 'Hausaufgaben machen', 'Papierkram sortieren', ]; + const BUSINESS_FILE_NAMES = [ + 'Neue Preisliste', 'Produktübersicht', 'Kundenbewertungen', 'Jahresbericht', 'Projektplan', + 'Angebotsvorschlag', 'Marketingstrategie', 'Lieferantenvertrag', 'Finanzplan', 'Geschäftsbericht', + 'Firmenpräsentation', 'Qualitätsmanagementplan', 'Vertriebsstrategie', 'Mitarbeiterhandbuch', 'Umsatzprognose', + 'Kundenvertrag', 'Marktanalyse', 'Entwicklungsplan', 'Vermarktungsplan', 'Forschungsbericht', + 'Investitionsplan', 'Risikoanalyse', 'Besprechungsprotokoll', 'Kundenbefragung', 'Personalplanung', + 'Produktentwicklungsbericht', 'Budgetplan', 'Geschäftsplanung', 'Kundenreferenzliste', 'Präsentationsvorlage', + 'Beschwerdeprotokoll', 'Verkaufsanalyse', 'Zielvereinbarung', 'Produktkonzept', 'Kundendatenbank', + 'Marketingkampagne', 'Projektabschlussbericht', 'Erfolgsbilanz', 'Strategieplan', 'Vertragsentwurf', + 'Kundendienstleistungsbericht', 'Verkaufsprognose', 'Geschäftsordnung', 'Produktbeschreibung', 'Rechnungsvorlage', + 'Personalakte', 'Dienstleistungsvertrag', 'Kundenbestellformular', 'Geschäftsbrief', 'Qualitätskontrollbericht' + ]; + } \ No newline at end of file diff --git a/src/Entity/DocumentObject.php b/src/Entity/DocumentObject.php index ecc47fd..25c7b84 100644 --- a/src/Entity/DocumentObject.php +++ b/src/Entity/DocumentObject.php @@ -16,6 +16,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Post; use ApiPlatform\OpenApi\Model; use App\Controller\CreateDocumentObjectAction; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\Serializer\Annotation\Groups; @@ -39,6 +40,14 @@ use Vich\UploaderBundle\Mapping\Annotation as Vich; 'schema' => [ 'type' => 'object', 'properties' => [ + 'name' => [ + 'type' => 'string', + 'required' => true, + ], + 'description' => [ + 'type' => 'text', + 'required' => true, + ], 'file' => [ 'type' => 'string', 'format' => 'binary' @@ -59,7 +68,7 @@ use Vich\UploaderBundle\Mapping\Annotation as Vich; ]) ) ), - validationContext: ['groups' => ['Default', 'document_object_create']], + validationContext: ['groups' => ['Default', 'document_object:create']], deserialize: false ), new Delete( @@ -75,26 +84,44 @@ class DocumentObject #[ORM\Id, ORM\Column, ORM\GeneratedValue] private ?int $id = null; + #[Groups(['document_object:read', 'document_object:create'])] + #[ORM\ManyToOne(inversedBy: 'documentObjects')] + #[ORM\JoinColumn(nullable: false)] + private ?User $createdBy = null; + + #[Groups(['document_object:read', 'document_object:create'])] + #[Assert\NotNull(groups: ['document_object:create'])] + #[ORM\Column(length: 255)] + private ?string $name = null; + + #[Groups(['document_object:read', 'document_object:create'])] + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $description = null; + + #[Groups(['document_object:read', 'document_object:create'])] + #[ORM\ManyToOne(inversedBy: 'documentObjects')] + private ?Partner $partner = null; + + #[Groups(['document_object:read', 'document_object:create'])] + #[ORM\ManyToOne(inversedBy: 'documentObjects')] + private ?Product $product = null; + #[ApiProperty(types: ['https://schema.org/contentUrl'])] #[Groups(['document_object:read'])] public ?string $contentUrl = null; #[Vich\UploadableField(mapping: 'document_object', fileNameProperty: 'filePath')] - #[Assert\NotNull(groups: ['document_object_create'])] + #[Assert\NotNull(groups: ['document_object:create'])] public ?File $file = null; #[ORM\Column(nullable: true)] public ?string $filePath = null; + #[ApiProperty(writable: false)] + #[Groups(['document_object:read'])] #[ORM\Column] private ?\DateTimeImmutable $createdAt = null; - #[ORM\ManyToOne(inversedBy: 'documentObjects')] - private ?Partner $partner = null; - - #[ORM\ManyToOne(inversedBy: 'documentObjects')] - private ?Product $product = null; - public function __construct() { $this->createdAt = new \DateTimeImmutable(); @@ -149,4 +176,40 @@ class DocumentObject return $this; } + public function getCreatedBy(): ?User + { + return $this->createdBy; + } + + public function setCreatedBy(?User $createdBy): static + { + $this->createdBy = $createdBy; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): static + { + $this->description = $description; + + return $this; + } + } \ No newline at end of file diff --git a/src/Entity/MediaObject.php b/src/Entity/MediaObject.php index d2658a4..4c4d12b 100644 --- a/src/Entity/MediaObject.php +++ b/src/Entity/MediaObject.php @@ -48,7 +48,7 @@ use Vich\UploaderBundle\Mapping\Annotation as Vich; ]) ) ), - validationContext: ['groups' => ['Default', 'media_object_create']], + validationContext: ['groups' => ['Default', 'media_object:create']], deserialize: false ), new Delete( @@ -68,7 +68,7 @@ class MediaObject public ?string $contentUrl = null; #[Vich\UploadableField(mapping: 'media_object', fileNameProperty: 'filePath')] - #[Assert\NotNull(groups: ['media_object_create'])] + #[Assert\NotNull(groups: ['media_object:create'])] public ?File $file = null; #[ORM\Column(nullable: true)] diff --git a/src/Entity/User.php b/src/Entity/User.php index 0430bfd..6b8e938 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -58,6 +58,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\OneToMany(mappedBy: 'user', targetEntity: PartnerFollow::class)] private Collection $partnerFollows; + #[ORM\OneToMany(mappedBy: 'createdBy', targetEntity: DocumentObject::class)] + private Collection $documentObjects; + public function __construct() { @@ -67,6 +70,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface $this->comments = new ArrayCollection(); $this->sales = new ArrayCollection(); $this->partnerFollows = new ArrayCollection(); + $this->documentObjects = new ArrayCollection(); } public function getId(): ?int @@ -314,4 +318,34 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + + /** + * @return Collection + */ + public function getDocumentObjects(): Collection + { + return $this->documentObjects; + } + + public function addDocumentObject(DocumentObject $documentObject): static + { + if (!$this->documentObjects->contains($documentObject)) { + $this->documentObjects->add($documentObject); + $documentObject->setCreatedBy($this); + } + + return $this; + } + + public function removeDocumentObject(DocumentObject $documentObject): static + { + if ($this->documentObjects->removeElement($documentObject)) { + // set the owning side to null (unless already changed) + if ($documentObject->getCreatedBy() === $this) { + $documentObject->setCreatedBy(null); + } + } + + return $this; + } } diff --git a/src/Factory/DocumentObjectFactory.php b/src/Factory/DocumentObjectFactory.php index 5df11db..09844c1 100644 --- a/src/Factory/DocumentObjectFactory.php +++ b/src/Factory/DocumentObjectFactory.php @@ -2,6 +2,7 @@ namespace App\Factory; +use App\DataFixtures\FakeValues; use App\Entity\DocumentObject; use Doctrine\ORM\EntityRepository; use Symfony\Component\HttpKernel\KernelInterface; @@ -62,8 +63,11 @@ final class DocumentObjectFactory extends ModelFactory $randBool = (bool)random_int(0, 1); return [ 'file' => new ReplacingFile($randomFile), + 'createdBy' => UserFactory::random(), + 'name' => self::faker()->randomElement(FakeValues::BUSINESS_FILE_NAMES), + 'description' => self::faker()->text(), 'partner' => $randBool ? PartnerFactory::random() : null, - 'product' => !$randBool ? ProductFactory::random() : null + 'product' => !$randBool ? ProductFactory::random() : null, ]; } diff --git a/src/Filter/SearchMultiFieldsFilter.php b/src/Filter/SearchMultiFieldsFilter.php new file mode 100644 index 0000000..7e1e5ed --- /dev/null +++ b/src/Filter/SearchMultiFieldsFilter.php @@ -0,0 +1,199 @@ + + * @date 22.03.24 + */ +// https://gist.github.com/axelvnk/edf879af5c7dbd9616a4eeb77c7181a3 + +namespace App\Filter; + + +use ApiPlatform\Api\IdentifiersExtractorInterface; +use ApiPlatform\Api\IriConverterInterface; +use ApiPlatform\Doctrine\Common\Filter\SearchFilterInterface; +use ApiPlatform\Doctrine\Common\Filter\SearchFilterTrait; +use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Operation; +use Closure; +use Doctrine\ORM\Query\Expr\Join; +use Doctrine\ORM\QueryBuilder; +use Doctrine\Persistence\ManagerRegistry; +use Psr\Log\LoggerInterface; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +final class SearchMultiFieldsFilter extends AbstractFilter implements SearchFilterInterface +{ + use SearchFilterTrait; + + public function __construct( + ManagerRegistry $managerRegistry, + IriConverterInterface $iriConverter, + ?PropertyAccessorInterface $propertyAccessor = null, + ?LoggerInterface $logger = null, + ?array $properties = null, + ?IdentifiersExtractorInterface $identifiersExtractor = null, + ?NameConverterInterface $nameConverter = null, + public string $searchParameterName = 'search', + ) { + parent::__construct($managerRegistry, $logger, $properties, $nameConverter); + + $this->iriConverter = $iriConverter; + $this->identifiersExtractor = $identifiersExtractor; + $this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor(); + } + + protected function getIriConverter(): IriConverterInterface + { + return $this->iriConverter; + } + + protected function getPropertyAccessor(): PropertyAccessorInterface + { + return $this->propertyAccessor; + } + + /** + * {@inheritDoc} + */ + protected function filterProperty( + string $property, + mixed $value, + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + ?Operation $operation = null, + array $context = [], + ): void { + if ( + null === $value + || $property !== $this->searchParameterName + ) { + return; + } + + $alias = $queryBuilder->getRootAliases()[0]; + $ors = []; + $count = 0; + + foreach (($this->getProperties() ?? []) as $prop => $caseSensitive) { + $filter = $this->generatePropertyOrWhere( + $queryBuilder, + $queryNameGenerator, + $alias, + $prop, + $value, + $resourceClass, + $count, + $caseSensitive ?? false, + ); + + if (null === $filter) { + continue; + } + + [$expr, $exprParams] = $filter; + $ors[] = $expr; + + $queryBuilder->setParameter($exprParams[1], $exprParams[0]); + + ++$count; + } + + $queryBuilder->andWhere($queryBuilder->expr()->orX(...$ors)); + } + + protected function generatePropertyOrWhere( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $alias, + string $property, + string $value, + string $resourceClass, + int $key, + bool $caseSensitive = false, + ): ?array { + if ( + !$this->isPropertyEnabled($property, $resourceClass) + || !$this->isPropertyMapped($property, $resourceClass, true) + ) { + return null; + } + + $field = $property; + $associations = []; + + if ($this->isPropertyNested($property, $resourceClass)) { + [$alias, $field, $associations] = $this->addJoinsForNestedProperty( + $property, + $alias, + $queryBuilder, + $queryNameGenerator, + $resourceClass, + Join::INNER_JOIN, + ); + } + + $metadata = $this->getNestedMetadata($resourceClass, $associations); + + if ( + 'id' === $field + || !$metadata->hasField($field) + ) { + return null; + } + + $wrapCase = $this->createWrapCase($caseSensitive); + $valueParameter = ':' . $queryNameGenerator->generateParameterName($field); + $aliasedField = sprintf('%s.%s', $alias, $field); + $keyValueParameter = sprintf('%s_%s', $valueParameter, $key); + + return [ + $queryBuilder->expr()->like( + $wrapCase($aliasedField), + $wrapCase((string) $queryBuilder->expr()->concat("'%'", $keyValueParameter, "'%'")), + ), + [$caseSensitive ? $value : strtolower($value), $keyValueParameter], + ]; + } + + protected function createWrapCase(bool $caseSensitive): Closure + { + return static function (string $expr) use ($caseSensitive): string { + if ($caseSensitive) { + return $expr; + } + + return sprintf('LOWER(%s)', $expr); + }; + } + + /** + * {@inheritDoc} + */ + protected function getType(string $doctrineType): string + { + return 'string'; + } + + public function getDescription(string $resourceClass): array + { + $props = $this->getProperties(); + + if (null === $props) { + throw new InvalidArgumentException('Properties must be specified'); + } + + return [ + $this->searchParameterName => [ + 'property' => implode(', ', array_keys($props)), + 'type' => 'string', + 'required' => false, + 'description' => 'Recherche sur les propriétés spécifiées.', + ], + ]; + } +} \ No newline at end of file