feat: Themes (#257)

* feat: add trait to unify get by id or slug logic

* feat: add Theme resource

* fix: categories and themes

* feat: update api docs for projects to themes relationship

* feat: update doctrine relationship of projects to themes

* feat: add v3 categories to v4 themes pumping during projects pumping
This commit is contained in:
2026-10-08 13:02:07 +02:00
committed by GitHub
parent 14ee28be4f
commit ac47aec378
20 changed files with 562 additions and 50 deletions
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261007154315 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add theme tables';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+2 -3
View File
@@ -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.
@@ -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.
*/
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\ApiResource;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\Entity\Theme;
use App\State\ApiResourceStateProcessor;
use App\State\ApiResourceStateProvider;
use App\State\ThemeStateProvider;
use Symfony\Component\Validator\Constraints as Assert;
/**
* A Theme, like in books, works as a "topic intent" grouping, for example:
* friendship, good vs. evil, power and corruption, etc.\
* \
* Themes 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.
*/
#[API\ApiResource(
shortName: 'Theme',
stateOptions: new Options(entityClass: Theme::class),
provider: ApiResourceStateProvider::class,
processor: ApiResourceStateProcessor::class,
)]
#[API\GetCollection()]
#[API\Post(security: 'is_granted("ROLE_ADMIN")')]
#[API\Get(
provider: ThemeStateProvider::class,
uriTemplate: '/themes/{idOrSlug}',
uriVariables: [
'idOrSlug' => 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;
}
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace App\Benzina;
use App\Entity\Theme;
use App\Repository\ThemeRepository;
use Symfony\Contracts\Service\Attribute\Required;
trait CategorizationPumpTrait
{
private ThemeRepository $themeRepository;
private const THEMES = [
'education' => '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;
}
}
+25
View File
@@ -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);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Dto;
trait CategorizedInputDtoTrait
{
public const array CATEGORIES_OPENAPI_CONTEXT = [
'type' => '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',
],
];
}
-19
View File
@@ -1,19 +0,0 @@
<?php
namespace App\Dto;
trait CategoryInputDtoTrait
{
public const array CATEGORIES_OPENAPI_CONTEXT = [
'type' => 'array',
'items' => [
'type' => 'string',
'format' => 'iri-reference',
'example' => '/categories/music',
],
'example' => [
'/categories/music',
'/categories/art',
],
];
}
+11 -1
View File
@@ -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.
*/
+11 -1
View File
@@ -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.
*/
+32
View File
@@ -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<int, Theme>
*/
#[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<int, Theme>
*/
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;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Entity;
use App\Repository\ThemeRepository;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Mapping\Annotation as Gedmo;
#[ORM\Entity(repositoryClass: ThemeRepository::class)]
class Theme
{
use LocalizedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 56, unique: true)]
#[Gedmo\Slug(fields: ['name'])]
private ?string $slug = null;
#[ORM\Column(length: 255)]
#[Gedmo\Translatable()]
private ?string $name = null;
public function getId(): ?int
{
return $this->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;
}
}
+6
View File
@@ -41,7 +41,13 @@ trait RedocExtensionsTrait
'ProjectSupport',
'ProjectCollaboration',
'ProjectCollaborationCandidacy',
],
],
[
'name' => 'Categorization',
'tags' => [
'Category',
'Theme',
],
],
[
+2
View File
@@ -11,6 +11,8 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class CategoryRepository extends ServiceEntityRepository
{
use FindByIdOrSlugTrait;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Category::class);
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Repository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
/**
* @template T of object
*
* @mixin ServiceEntityRepository
*/
trait FindByIdOrSlugTrait
{
/**
* @return T|null
*/
public function findOneByIdOrSlug(mixed $idOrSlug): ?object
{
if (\ctype_digit((string) $idOrSlug)) {
return $this->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;
}
}
+3 -9
View File
@@ -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
// */
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Repository;
use App\Entity\Theme;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Theme>
*/
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()
// ;
// }
}
+4 -10
View File
@@ -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);
}
}
+1 -7
View File
@@ -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) {
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\ThemeApiResource;
use App\Doctrine\LocalizedExtensionTrait;
use App\Mapping\AutoMapper;
use App\Repository\ThemeRepository;
class ThemeStateProvider implements ProviderInterface
{
use LocalizedExtensionTrait;
public function __construct(
private ThemeRepository $themeRepository,
private AutoMapper $autoMapper,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$queryBuilder = $this->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);
}
}