diff --git a/migrations/Version20261007154315.php b/migrations/Version20261007154315.php new file mode 100644 index 0000000..2f5c97b --- /dev/null +++ b/migrations/Version20261007154315.php @@ -0,0 +1,32 @@ +addSql('CREATE TABLE project_theme (project_id INT NOT NULL, theme_id INT NOT NULL, INDEX IDX_8420EE96166D1F9C (project_id), INDEX IDX_8420EE9659027487 (theme_id), PRIMARY KEY(project_id, theme_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE theme (id INT AUTO_INCREMENT NOT NULL, slug VARCHAR(56) NOT NULL, name VARCHAR(255) NOT NULL, locales JSON NOT NULL COMMENT \'(DC2Type:json)\', UNIQUE INDEX UNIQ_9775E708989D9B62 (slug), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE project_theme ADD CONSTRAINT FK_8420EE96166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE project_theme ADD CONSTRAINT FK_8420EE9659027487 FOREIGN KEY (theme_id) REFERENCES theme (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE project_theme DROP FOREIGN KEY FK_8420EE96166D1F9C'); + $this->addSql('ALTER TABLE project_theme DROP FOREIGN KEY FK_8420EE9659027487'); + $this->addSql('DROP TABLE project_theme'); + $this->addSql('DROP TABLE theme'); + } +} diff --git a/src/ApiResource/CategoryApiResource.php b/src/ApiResource/CategoryApiResource.php index 71f7b49..02a2b21 100644 --- a/src/ApiResource/CategoryApiResource.php +++ b/src/ApiResource/CategoryApiResource.php @@ -12,9 +12,8 @@ use App\State\CategoryStateProvider; use Symfony\Component\Validator\Constraints as Assert; /** - * A Category can be used by other resources as a "topic intent".\ - * For example. Projects might relate with up to 2 Categories, which are used by the Project - * as a way to describe itself and can be used to discover similar Projects.\ + * A Category, like in books, is a way of grouping resources by type, genre, audience, or subject, for example: + * science-fiction, biography, fantasy, etc.\ * \ * Categories can only be modified by users with the role "ROLE_ADMIN", but can usually * be referenced by non-admin users in their own resources, such as Project owners. diff --git a/src/ApiResource/Project/ProjectApiResource.php b/src/ApiResource/Project/ProjectApiResource.php index c228fa0..191e55f 100644 --- a/src/ApiResource/Project/ProjectApiResource.php +++ b/src/ApiResource/Project/ProjectApiResource.php @@ -10,6 +10,7 @@ use App\ApiResource\Accounting\AccountingApiResource; use App\ApiResource\CategoryApiResource; use App\ApiResource\LocalizedApiResourceTrait; use App\ApiResource\Matchfunding\MatchCallSubmissionApiResource; +use App\ApiResource\ThemeApiResource; use App\ApiResource\TimestampedCreationApiResource; use App\ApiResource\TimestampedUpdationApiResource; use App\ApiResource\User\UserApiResource; @@ -134,6 +135,16 @@ class ProjectApiResource #[API\ApiFilter(filterClass: SearchFilter::class, strategy: 'exact')] public array $categories; + /** + * A list of the available Themes of this Project. + * + * @var ThemeApiResource[] + */ + #[Assert\NotBlank()] + #[API\ApiProperty(writableLink: false)] + #[API\ApiFilter(filterClass: SearchFilter::class, strategy: 'exact')] + public array $themes; + /** * ISO 3166 data about the Project's territory of interest. */ diff --git a/src/ApiResource/ThemeApiResource.php b/src/ApiResource/ThemeApiResource.php new file mode 100644 index 0000000..e087155 --- /dev/null +++ b/src/ApiResource/ThemeApiResource.php @@ -0,0 +1,64 @@ + new API\Link( + description: 'Theme identifier or slug', + ), + ] +)] +#[API\Patch(security: 'is_granted("ROLE_ADMIN")')] +#[API\Delete(security: 'is_granted("ROLE_ADMIN")')] +class ThemeApiResource +{ + use LocalizedApiResourceTrait; + + /** + * This value will identify this Theme in relationships with other resources. + */ + #[API\ApiProperty(identifier: true)] + public string $id; + + /** + * A unique, non white space, string identifier for this Theme. + */ + #[API\ApiProperty(writable: false)] + #[API\ApiFilter(SearchFilter::class, strategy: 'exact')] + public string $slug; + + /** + * A human-readable self-descriptive string of what this Theme is about. + */ + #[Assert\NotBlank()] + #[API\ApiProperty()] + #[API\ApiFilter(SearchFilter::class, strategy: 'partial')] + public string $name; +} diff --git a/src/Benzina/CategorizationPumpTrait.php b/src/Benzina/CategorizationPumpTrait.php new file mode 100644 index 0000000..3b3921e --- /dev/null +++ b/src/Benzina/CategorizationPumpTrait.php @@ -0,0 +1,157 @@ + 'Educación', + 'culture' => 'Cultura', + 'journalism' => 'Periodismo', + 'science-and-tech' => 'Ciencia y Tecnología', + 'rural-and-ecology' => 'Desarrollo rural y Transición ecológica', + 'health-and-cares' => 'Salud y Cuidados', + 'sustainability' => 'Sostenibilidad', + 'democracy' => 'Derechos y Democracia', + 'social-economy' => 'Economía social', + ]; + + #[Required] + public function setThemeRepository(ThemeRepository $themeRepository) + { + $this->themeRepository = $themeRepository; + } + + public function getTheme(string $slug): Theme + { + $theme = $this->themeRepository->findOneBy(['slug' => $slug]); + + if (!$theme) { + $theme = new Theme(); + $theme->setSlug($slug); + $theme->setName(self::THEMES[$slug]); + } + + $theme->addLocale('es'); + + return $theme; + } + + private function matchCategories(array $categories, array $names): bool + { + $categories = array_map(fn($c) => $c['name'], $categories); + $matches = 0; + + foreach ($names as $name) { + if (in_array($name, $categories)) { + $matches = $matches + 1; + } + } + + return $matches === count($names); + } + + private function mapOldCategoriesToThemes(string $socialCommitmentName, array $categories): array + { + $themes = []; + + if ( + $socialCommitmentName === 'Solidario' + && $this->matchCategories($categories, ['Social']) + ) { + $themes[] = $this->getTheme('democracy'); + } + + if ( + $socialCommitmentName === 'Software libre' + && $this->matchCategories($categories, ['Tecnológico', 'Científico']) + ) { + $themes[] = $this->getTheme('science-and-tech'); + } + + if ( + $socialCommitmentName === 'Generar empleo' + && $this->matchCategories($categories, ['Emprendedor', 'Social']) + ) { + $themes[] = $this->getTheme('social-economy'); + } + + if ( + $socialCommitmentName === 'Periodismo independiente' + && $this->matchCategories($categories, ['Comunicativo']) + ) { + $themes[] = $this->getTheme('journalism'); + } + + if ( + $socialCommitmentName === 'Educativo' + && $this->matchCategories($categories, ['Educativo']) + ) { + $themes[] = $this->getTheme('education'); + } + + if ( + $socialCommitmentName === 'Crear cultura' + && $this->matchCategories($categories, ['Cultural']) + ) { + $themes[] = $this->getTheme('culture'); + } + + if ( + $socialCommitmentName === 'Acción por el clima' + && $this->matchCategories($categories, ['Ecológico']) + ) { + $themes[] = $this->getTheme('sustainability'); + } + + if ( + $socialCommitmentName === 'Datos abiertos' + && $this->matchCategories($categories, ['Científico', 'Comunicativo']) + ) { + $themes[] = $this->getTheme('journalism'); + } + + if ( + $socialCommitmentName === 'Reforzar valores democráticos' + && $this->matchCategories($categories, ['Social']) + ) { + $themes[] = $this->getTheme('democracy'); + } + + if ( + $socialCommitmentName === 'Igualdad de Género' + && $this->matchCategories($categories, ['Social']) + ) { + $themes[] = $this->getTheme('democracy'); + } + + if ( + $socialCommitmentName === 'Salud y Cuidados' + && $this->matchCategories($categories, ['Social']) + ) { + $themes[] = $this->getTheme('health-and-cares'); + } + + if ( + $socialCommitmentName === 'Energía y sostenibilidad' + && $this->matchCategories($categories, ['Científico', 'Ecológico']) + ) { + $themes[] = $this->getTheme('science-and-tech'); + } + + if ( + $socialCommitmentName === 'Desarrollo agrorural' + && $this->matchCategories($categories, ['Ecológico', 'Emprendedor']) + ) { + $themes[] = $this->getTheme('rural-and-ecology'); + } + + return $themes; + } +} diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index eddaf30..be316eb 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -27,6 +27,7 @@ class ProjectsPump implements PumpInterface use ProjectsPumpTrait; use LocalizedPumpTrait; use TerritoryPumpTrait; + use CategorizationPumpTrait; public function __construct( private ProjectRepository $projectRepository, @@ -106,6 +107,11 @@ class ProjectsPump implements PumpInterface $project->setDescGoal($record['motivation']); $project->setDescTeam($record['related']); + $themes = $this->getProjectThemes($record, $context); + foreach ($themes as $theme) { + $project->addTheme($theme); + } + $this->setPreventFlushAndClear(true); $this->persist($project, $context); @@ -407,4 +413,23 @@ class ProjectsPump implements PumpInterface return $collaborations; } + + private function getProjectThemes(array $record, array $context): array + { + $scQuery = $this->getDbConnection($context)->prepare( + 'SELECT * FROM `social_commitment` sc WHERE sc.id = :scId' + ); + + $scQuery->execute(['scId' => $record['social_commitment']]); + $socialCommitment = $scQuery->fetch(); + + $catsQuery = $this->getDbConnection($context)->prepare( + 'SELECT * FROM `category` c INNER JOIN `project_category` pc ON c.id = pc.category WHERE pc.project = :project' + ); + + $catsQuery->execute(['project' => $record['id']]); + $categories = $catsQuery->fetchAll(); + + return $this->mapOldCategoriesToThemes($socialCommitment['name'] ?? '', $categories); + } } diff --git a/src/Dto/CategorizedInputDtoTrait.php b/src/Dto/CategorizedInputDtoTrait.php new file mode 100644 index 0000000..075cf9a --- /dev/null +++ b/src/Dto/CategorizedInputDtoTrait.php @@ -0,0 +1,32 @@ + 'array', + 'items' => [ + 'type' => 'string', + 'format' => 'iri-reference', + 'example' => '/categories/cooperativism', + ], + 'example' => [ + '/categories/open-source-software', + '/categories/cooperativism', + ], + ]; + + public const array THEMES_OPENAPI_CONTEXT = [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'format' => 'iri-reference', + 'example' => '/themes/music', + ], + 'example' => [ + '/themes/music', + '/themes/art', + ], + ]; +} diff --git a/src/Dto/CategoryInputDtoTrait.php b/src/Dto/CategoryInputDtoTrait.php deleted file mode 100644 index a347c1d..0000000 --- a/src/Dto/CategoryInputDtoTrait.php +++ /dev/null @@ -1,19 +0,0 @@ - 'array', - 'items' => [ - 'type' => 'string', - 'format' => 'iri-reference', - 'example' => '/categories/music', - ], - 'example' => [ - '/categories/music', - '/categories/art', - ], - ]; -} diff --git a/src/Dto/ProjectCreationDto.php b/src/Dto/ProjectCreationDto.php index 50cf7c5..5a9daa5 100644 --- a/src/Dto/ProjectCreationDto.php +++ b/src/Dto/ProjectCreationDto.php @@ -4,6 +4,7 @@ namespace App\Dto; use ApiPlatform\Metadata as API; use App\ApiResource\CategoryApiResource; +use App\ApiResource\ThemeApiResource; use App\Entity\Project\Project; use App\Entity\Project\ProjectCalendar; use App\Entity\Project\ProjectStatus; @@ -13,7 +14,7 @@ use Symfony\Component\Validator\Constraints as Assert; class ProjectCreationDto { - use CategoryInputDtoTrait; + use CategorizedInputDtoTrait; /** * Main headline for the Project. Must include at least one character between a-Z. @@ -40,6 +41,15 @@ class ProjectCreationDto #[API\ApiProperty(writableLink: false, openapiContext: self::CATEGORIES_OPENAPI_CONTEXT)] public array $categories; + /** + * List of Themes. + * + * @var ThemeApiResource[] + */ + #[Assert\Count(max: 1)] + #[API\ApiProperty(writableLink: false, openapiContext: self::THEMES_OPENAPI_CONTEXT)] + public array $themes; + /** * ISO 3166 data about the Project's territory of interest. */ diff --git a/src/Dto/ProjectUpdationDto.php b/src/Dto/ProjectUpdationDto.php index 44a64c8..92fa747 100644 --- a/src/Dto/ProjectUpdationDto.php +++ b/src/Dto/ProjectUpdationDto.php @@ -4,6 +4,7 @@ namespace App\Dto; use ApiPlatform\Metadata as API; use App\ApiResource\CategoryApiResource; +use App\ApiResource\ThemeApiResource; use App\Entity\Project\Project; use App\Entity\Project\ProjectCalendar; use App\Entity\Project\ProjectDeadline; @@ -15,7 +16,7 @@ use Symfony\Component\Validator\Constraints as Assert; class ProjectUpdationDto { - use CategoryInputDtoTrait; + use CategorizedInputDtoTrait; #[API\ApiProperty(identifier: true, writable: false)] public int $id; @@ -47,6 +48,15 @@ class ProjectUpdationDto #[API\ApiProperty(writableLink: false, openapiContext: self::CATEGORIES_OPENAPI_CONTEXT)] public array $categories; + /** + * List of Themes. + * + * @var ThemeApiResource[] + */ + #[Assert\Count(max: 1)] + #[API\ApiProperty(writableLink: false, openapiContext: self::THEMES_OPENAPI_CONTEXT)] + public array $themes; + /** * ISO 3166 data about the Project's territory of interest. */ diff --git a/src/Entity/Project/Project.php b/src/Entity/Project/Project.php index 83859bb..310be3f 100644 --- a/src/Entity/Project/Project.php +++ b/src/Entity/Project/Project.php @@ -13,6 +13,7 @@ use App\Entity\Matchfunding\MatchCallSubmission; use App\Entity\Matchfunding\MatchCallSubmissionStatus; use App\Entity\MigratedTrait; use App\Entity\Territory; +use App\Entity\Theme; use App\Entity\User\User; use App\Entity\UserOwnedInterface; use App\Entity\UserOwnedTrait; @@ -175,6 +176,12 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized #[ORM\Column(nullable: true)] private ?array $links = null; + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Theme::class, cascade: ['persist'])] + private Collection $themes; + public function __construct() { $this->accounting = Accounting::of($this); @@ -186,6 +193,7 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized $this->categories = new ArrayCollection(); $this->collaborations = new ArrayCollection(); $this->reviews = new ArrayCollection(); + $this->themes = new ArrayCollection(); } public function getId(): ?int @@ -651,4 +659,28 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized return $this; } + + /** + * @return Collection + */ + public function getThemes(): Collection + { + return $this->themes; + } + + public function addTheme(Theme $theme): static + { + if (!$this->themes->contains($theme)) { + $this->themes->add($theme); + } + + return $this; + } + + public function removeTheme(Theme $theme): static + { + $this->themes->removeElement($theme); + + return $this; + } } diff --git a/src/Entity/Theme.php b/src/Entity/Theme.php new file mode 100644 index 0000000..bb81cf9 --- /dev/null +++ b/src/Entity/Theme.php @@ -0,0 +1,55 @@ +id; + } + + public function getSlug(): ?string + { + return $this->slug; + } + + public function setSlug(string $slug): static + { + $this->slug = $slug; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } +} diff --git a/src/OpenApi/RedocExtensionsTrait.php b/src/OpenApi/RedocExtensionsTrait.php index fa6d167..5fb0990 100644 --- a/src/OpenApi/RedocExtensionsTrait.php +++ b/src/OpenApi/RedocExtensionsTrait.php @@ -41,7 +41,13 @@ trait RedocExtensionsTrait 'ProjectSupport', 'ProjectCollaboration', 'ProjectCollaborationCandidacy', + ], + ], + [ + 'name' => 'Categorization', + 'tags' => [ 'Category', + 'Theme', ], ], [ diff --git a/src/Repository/CategoryRepository.php b/src/Repository/CategoryRepository.php index 2ea6ad9..8106795 100644 --- a/src/Repository/CategoryRepository.php +++ b/src/Repository/CategoryRepository.php @@ -11,6 +11,8 @@ use Doctrine\Persistence\ManagerRegistry; */ class CategoryRepository extends ServiceEntityRepository { + use FindByIdOrSlugTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Category::class); diff --git a/src/Repository/FindByIdOrSlugTrait.php b/src/Repository/FindByIdOrSlugTrait.php new file mode 100644 index 0000000..bb9abc7 --- /dev/null +++ b/src/Repository/FindByIdOrSlugTrait.php @@ -0,0 +1,36 @@ +find($idOrSlug); + } + + return $this->findOneBy(['slug' => $idOrSlug]); + } + + public function getByIdOrSlugQuery(mixed $idOrSlug): QueryBuilder + { + /** @var QueryBuilder */ + $queryBuilder = $this->createQueryBuilder('o'); + $queryBuilder->where(\ctype_digit((string) $idOrSlug) ? 'o.id = :idOrSlugField' : 'o.slug = :idOrSlugField'); + $queryBuilder->setParameter('idOrSlugField', $idOrSlug); + + return $queryBuilder; + } +} diff --git a/src/Repository/Project/ProjectRepository.php b/src/Repository/Project/ProjectRepository.php index 17849d2..1764372 100644 --- a/src/Repository/Project/ProjectRepository.php +++ b/src/Repository/Project/ProjectRepository.php @@ -4,6 +4,7 @@ namespace App\Repository\Project; use App\Entity\Accounting\Accounting; use App\Entity\Project\Project; +use App\Repository\FindByIdOrSlugTrait; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -17,6 +18,8 @@ use Doctrine\Persistence\ManagerRegistry; */ class ProjectRepository extends ServiceEntityRepository { + use FindByIdOrSlugTrait; + public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Project::class); @@ -31,15 +34,6 @@ class ProjectRepository extends ServiceEntityRepository ->getOneOrNullResult(); } - public function findOneByIdOrSlug(mixed $idOrSlug): ?Project - { - if (\is_numeric($idOrSlug)) { - return $this->find($idOrSlug); - } - - return $this->findOneBy(['slug' => $idOrSlug]); - } - // /** // * @return Project[] Returns an array of Project objects // */ diff --git a/src/Repository/ThemeRepository.php b/src/Repository/ThemeRepository.php new file mode 100644 index 0000000..87ec402 --- /dev/null +++ b/src/Repository/ThemeRepository.php @@ -0,0 +1,45 @@ + + */ +class ThemeRepository extends ServiceEntityRepository +{ + use FindByIdOrSlugTrait; + + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Theme::class); + } + + // /** + // * @return Theme[] Returns an array of Theme objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('t') + // ->andWhere('t.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('t.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Theme + // { + // return $this->createQueryBuilder('t') + // ->andWhere('t.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/State/CategoryStateProvider.php b/src/State/CategoryStateProvider.php index f832bff..554cd75 100644 --- a/src/State/CategoryStateProvider.php +++ b/src/State/CategoryStateProvider.php @@ -20,20 +20,14 @@ class CategoryStateProvider implements ProviderInterface public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - $idOrSlug = $uriVariables['idOrSlug']; - - $queryBuilder = $this->categoryRepository->createQueryBuilder('c'); - $queryBuilder->where(\is_numeric($idOrSlug) ? 'c.id = :value' : 'c.slug = :value'); - $queryBuilder->setParameter('value', $idOrSlug); - + $queryBuilder = $this->categoryRepository->getByIdOrSlugQuery($uriVariables['idOrSlug']); $query = $this->addLocalizationHints($queryBuilder, $this->getAcceptedLanguages($context)); + $category = $query->getOneOrNullResult(); - $project = $query->getOneOrNullResult(); - - if ($project === null) { + if ($category === null) { return null; } - return $this->autoMapper->map($project, CategoryApiResource::class); + return $this->autoMapper->map($category, CategoryApiResource::class); } } diff --git a/src/State/Project/ProjectStateProvider.php b/src/State/Project/ProjectStateProvider.php index 418548e..77cc230 100644 --- a/src/State/Project/ProjectStateProvider.php +++ b/src/State/Project/ProjectStateProvider.php @@ -20,14 +20,8 @@ class ProjectStateProvider implements ProviderInterface public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - $idOrSlug = $uriVariables['idOrSlug']; - - $queryBuilder = $this->projectRepository->createQueryBuilder('p'); - $queryBuilder->where(\is_numeric($idOrSlug) ? 'p.id = :value' : 'p.slug = :value'); - $queryBuilder->setParameter('value', $idOrSlug); - + $queryBuilder = $this->projectRepository->getByIdOrSlugQuery($uriVariables['idOrSlug']); $query = $this->addLocalizationHints($queryBuilder, $this->getAcceptedLanguages($context)); - $project = $query->getOneOrNullResult(); if ($project === null) { diff --git a/src/State/ThemeStateProvider.php b/src/State/ThemeStateProvider.php new file mode 100644 index 0000000..2d4a467 --- /dev/null +++ b/src/State/ThemeStateProvider.php @@ -0,0 +1,33 @@ +themeRepository->getByIdOrSlugQuery($uriVariables['idOrSlug']); + $query = $this->addLocalizationHints($queryBuilder, $this->getAcceptedLanguages($context)); + $theme = $query->getOneOrNullResult(); + + if ($theme === null) { + return null; + } + + return $this->autoMapper->map($theme, ThemeApiResource::class); + } +}