| @@ -0,0 +1,35 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace DoctrineMigrations; | |||
| use Doctrine\DBAL\Schema\Schema; | |||
| use Doctrine\Migrations\AbstractMigration; | |||
| /** | |||
| * Auto-generated Migration: Please modify to your needs! | |||
| */ | |||
| final class Version20240322155533 extends AbstractMigration | |||
| { | |||
| public function getDescription(): string | |||
| { | |||
| return ''; | |||
| } | |||
| public function up(Schema $schema): void | |||
| { | |||
| // this up() migration is auto-generated, please modify it to your needs | |||
| $this->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'); | |||
| } | |||
| } | |||
| @@ -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)] | |||
| @@ -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' | |||
| ]; | |||
| } | |||
| @@ -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; | |||
| } | |||
| } | |||
| @@ -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)] | |||
| @@ -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<int, DocumentObject> | |||
| */ | |||
| 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; | |||
| } | |||
| } | |||
| @@ -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, | |||
| ]; | |||
| } | |||
| @@ -0,0 +1,199 @@ | |||
| <?php | |||
| /** | |||
| * @author Daniel Knudsen <d.knudsen@spawntree.de> | |||
| * @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.', | |||
| ], | |||
| ]; | |||
| } | |||
| } | |||