| @@ -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), | stateOptions: new Options(entityClass: User::class), | ||||
| )] | )] | ||||
| #[ApiFilter(SearchFilter::class, properties: ['firstName' => 'partial', 'lastName' => 'partial'])] | |||||
| #[ApiFilter(SearchFilter::class, properties: ['firstName' => 'ipartial', 'lastName' => 'ipartial'])] | |||||
| class UserApi | class UserApi | ||||
| { | { | ||||
| #[ApiProperty(readable: false, writable: false, identifier: true)] | #[ApiProperty(readable: false, writable: false, identifier: true)] | ||||
| @@ -221,4 +221,17 @@ class FakeValues | |||||
| 'Hausaufgaben machen', 'Papierkram sortieren', | '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\Metadata\Post; | ||||
| use ApiPlatform\OpenApi\Model; | use ApiPlatform\OpenApi\Model; | ||||
| use App\Controller\CreateDocumentObjectAction; | use App\Controller\CreateDocumentObjectAction; | ||||
| use Doctrine\DBAL\Types\Types; | |||||
| use Doctrine\ORM\Mapping as ORM; | use Doctrine\ORM\Mapping as ORM; | ||||
| use Symfony\Component\HttpFoundation\File\File; | use Symfony\Component\HttpFoundation\File\File; | ||||
| use Symfony\Component\Serializer\Annotation\Groups; | use Symfony\Component\Serializer\Annotation\Groups; | ||||
| @@ -39,6 +40,14 @@ use Vich\UploaderBundle\Mapping\Annotation as Vich; | |||||
| 'schema' => [ | 'schema' => [ | ||||
| 'type' => 'object', | 'type' => 'object', | ||||
| 'properties' => [ | 'properties' => [ | ||||
| 'name' => [ | |||||
| 'type' => 'string', | |||||
| 'required' => true, | |||||
| ], | |||||
| 'description' => [ | |||||
| 'type' => 'text', | |||||
| 'required' => true, | |||||
| ], | |||||
| 'file' => [ | 'file' => [ | ||||
| 'type' => 'string', | 'type' => 'string', | ||||
| 'format' => 'binary' | '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 | deserialize: false | ||||
| ), | ), | ||||
| new Delete( | new Delete( | ||||
| @@ -75,26 +84,44 @@ class DocumentObject | |||||
| #[ORM\Id, ORM\Column, ORM\GeneratedValue] | #[ORM\Id, ORM\Column, ORM\GeneratedValue] | ||||
| private ?int $id = null; | 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'])] | #[ApiProperty(types: ['https://schema.org/contentUrl'])] | ||||
| #[Groups(['document_object:read'])] | #[Groups(['document_object:read'])] | ||||
| public ?string $contentUrl = null; | public ?string $contentUrl = null; | ||||
| #[Vich\UploadableField(mapping: 'document_object', fileNameProperty: 'filePath')] | #[Vich\UploadableField(mapping: 'document_object', fileNameProperty: 'filePath')] | ||||
| #[Assert\NotNull(groups: ['document_object_create'])] | |||||
| #[Assert\NotNull(groups: ['document_object:create'])] | |||||
| public ?File $file = null; | public ?File $file = null; | ||||
| #[ORM\Column(nullable: true)] | #[ORM\Column(nullable: true)] | ||||
| public ?string $filePath = null; | public ?string $filePath = null; | ||||
| #[ApiProperty(writable: false)] | |||||
| #[Groups(['document_object:read'])] | |||||
| #[ORM\Column] | #[ORM\Column] | ||||
| private ?\DateTimeImmutable $createdAt = null; | private ?\DateTimeImmutable $createdAt = null; | ||||
| #[ORM\ManyToOne(inversedBy: 'documentObjects')] | |||||
| private ?Partner $partner = null; | |||||
| #[ORM\ManyToOne(inversedBy: 'documentObjects')] | |||||
| private ?Product $product = null; | |||||
| public function __construct() | public function __construct() | ||||
| { | { | ||||
| $this->createdAt = new \DateTimeImmutable(); | $this->createdAt = new \DateTimeImmutable(); | ||||
| @@ -149,4 +176,40 @@ class DocumentObject | |||||
| return $this; | 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 | deserialize: false | ||||
| ), | ), | ||||
| new Delete( | new Delete( | ||||
| @@ -68,7 +68,7 @@ class MediaObject | |||||
| public ?string $contentUrl = null; | public ?string $contentUrl = null; | ||||
| #[Vich\UploadableField(mapping: 'media_object', fileNameProperty: 'filePath')] | #[Vich\UploadableField(mapping: 'media_object', fileNameProperty: 'filePath')] | ||||
| #[Assert\NotNull(groups: ['media_object_create'])] | |||||
| #[Assert\NotNull(groups: ['media_object:create'])] | |||||
| public ?File $file = null; | public ?File $file = null; | ||||
| #[ORM\Column(nullable: true)] | #[ORM\Column(nullable: true)] | ||||
| @@ -58,6 +58,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface | |||||
| #[ORM\OneToMany(mappedBy: 'user', targetEntity: PartnerFollow::class)] | #[ORM\OneToMany(mappedBy: 'user', targetEntity: PartnerFollow::class)] | ||||
| private Collection $partnerFollows; | private Collection $partnerFollows; | ||||
| #[ORM\OneToMany(mappedBy: 'createdBy', targetEntity: DocumentObject::class)] | |||||
| private Collection $documentObjects; | |||||
| public function __construct() | public function __construct() | ||||
| { | { | ||||
| @@ -67,6 +70,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface | |||||
| $this->comments = new ArrayCollection(); | $this->comments = new ArrayCollection(); | ||||
| $this->sales = new ArrayCollection(); | $this->sales = new ArrayCollection(); | ||||
| $this->partnerFollows = new ArrayCollection(); | $this->partnerFollows = new ArrayCollection(); | ||||
| $this->documentObjects = new ArrayCollection(); | |||||
| } | } | ||||
| public function getId(): ?int | public function getId(): ?int | ||||
| @@ -314,4 +318,34 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface | |||||
| return $this; | 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; | namespace App\Factory; | ||||
| use App\DataFixtures\FakeValues; | |||||
| use App\Entity\DocumentObject; | use App\Entity\DocumentObject; | ||||
| use Doctrine\ORM\EntityRepository; | use Doctrine\ORM\EntityRepository; | ||||
| use Symfony\Component\HttpKernel\KernelInterface; | use Symfony\Component\HttpKernel\KernelInterface; | ||||
| @@ -62,8 +63,11 @@ final class DocumentObjectFactory extends ModelFactory | |||||
| $randBool = (bool)random_int(0, 1); | $randBool = (bool)random_int(0, 1); | ||||
| return [ | return [ | ||||
| 'file' => new ReplacingFile($randomFile), | 'file' => new ReplacingFile($randomFile), | ||||
| 'createdBy' => UserFactory::random(), | |||||
| 'name' => self::faker()->randomElement(FakeValues::BUSINESS_FILE_NAMES), | |||||
| 'description' => self::faker()->text(), | |||||
| 'partner' => $randBool ? PartnerFactory::random() : null, | '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.', | |||||
| ], | |||||
| ]; | |||||
| } | |||||
| } | |||||