Merge pull request #232 from goteo/feat/bump-benzina

feat: Bump goteo/benzina
This commit is contained in:
2026-08-14 16:06:49 +02:00
committed by GitHub
21 changed files with 533 additions and 358 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
"doctrine/orm": "^3.3",
"embed/embed": "^4.4",
"gedmo/doctrine-extensions": "^3.22",
"goteo/benzina": "^0.5",
"goteo/benzina": "^0.6.0",
"jolicode/automapper": "9.2.1",
"knpuniversity/oauth2-client-bundle": "^2.20",
"kyzegs/doctrine-encryption-bundle": "^1.1",
Generated
+214 -276
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260812124026 extends AbstractMigration
{
public function getDescription(): string
{
return 'Make territory_address LONGTEXT';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE match_call CHANGE territory_address territory_address LONGTEXT DEFAULT NULL');
$this->addSql('ALTER TABLE project CHANGE territory_address territory_address LONGTEXT DEFAULT NULL');
$this->addSql('ALTER TABLE user CHANGE territory_address territory_address LONGTEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE user CHANGE territory_address territory_address VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE project CHANGE territory_address territory_address VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE ext_translations CHANGE foreign_key foreign_key INT NOT NULL');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260813144440 extends AbstractMigration
{
public function getDescription(): string
{
return 'Split description into: brief, about, goal and team';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE project ADD desc_about LONGTEXT DEFAULT NULL, ADD desc_goal LONGTEXT DEFAULT NULL, ADD desc_team LONGTEXT DEFAULT NULL, CHANGE description desc_brief LONGTEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE project ADD description LONGTEXT DEFAULT NULL, DROP desc_brief, DROP desc_about, DROP desc_goal, DROP desc_team');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?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 Version20260813171902 extends AbstractMigration
{
public function getDescription(): string
{
return 'Update categories to work with INT ids and VARCHAR slugs';
}
public function up(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS category');
$this->addSql('DROP TABLE IF EXISTS project_category');
$this->addSql('CREATE TABLE category (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_64C19C1989D9B62 (slug), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE project_category (project_id INT NOT NULL, category_id INT NOT NULL, INDEX IDX_3B02921A166D1F9C (project_id), INDEX IDX_3B02921A12469DE2 (category_id), PRIMARY KEY(project_id, category_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE project_category ADD CONSTRAINT FK_3B02921A166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE project_category ADD CONSTRAINT FK_3B02921A12469DE2 FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS category');
$this->addSql('DROP TABLE IF EXISTS project_category');
$this->addSql('CREATE TABLE category (id VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, locales JSON NOT NULL COMMENT \'(DC2Type:json)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE project_category (project_id INT NOT NULL, category_id VARCHAR(255) NOT NULL, INDEX IDX_3B02921A166D1F9C (project_id), INDEX IDX_3B02921A12469DE2 (category_id), PRIMARY KEY(project_id, category_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE project_category ADD CONSTRAINT FK_3B02921A166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE project_category ADD CONSTRAINT FK_3B02921A12469DE2 FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE CASCADE');
}
}
+20 -3
View File
@@ -2,11 +2,13 @@
namespace App\ApiResource;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\Entity\Category;
use App\State\ApiResourceStateProcessor;
use App\State\ApiResourceStateProvider;
use App\State\CategoryStateProvider;
use Symfony\Component\Validator\Constraints as Assert;
/**
@@ -25,7 +27,15 @@ use Symfony\Component\Validator\Constraints as Assert;
)]
#[API\GetCollection()]
#[API\Post(security: 'is_granted("ROLE_ADMIN")')]
#[API\Get()]
#[API\Get(
provider: CategoryStateProvider::class,
uriTemplate: '/categories/{idOrSlug}',
uriVariables: [
'idOrSlug' => new API\Link(
description: 'Category identifier or slug',
),
]
)]
#[API\Patch(security: 'is_granted("ROLE_ADMIN")')]
#[API\Delete(security: 'is_granted("ROLE_ADMIN")')]
class CategoryApiResource
@@ -36,13 +46,20 @@ class CategoryApiResource
* This value will identify this Category in relationships with other resources.
*/
#[API\ApiProperty(identifier: true)]
#[Assert\NotBlank()]
public string $id;
/**
* A unique, non white space, string identifier for this Category.
*/
#[API\ApiProperty(writable: false)]
#[API\ApiFilter(SearchFilter::class, strategy: 'exact')]
public string $slug;
/**
* A human-readable self-descriptive string of what this Category is about.
*/
#[API\ApiProperty()]
#[Assert\NotBlank()]
#[API\ApiProperty()]
#[API\ApiFilter(SearchFilter::class, strategy: 'partial')]
public string $name;
}
+17 -3
View File
@@ -141,11 +141,25 @@ class ProjectApiResource
public Territory $territory;
/**
* Free-form rich text description for the Project.
* Rich-text (markdown) introduction to the project.
*/
#[API\ApiFilter(filterClass: SearchFilter::class, strategy: 'partial')]
#[Assert\NotBlank()]
public string $description;
public string $descBrief;
/**
* Rich-text (markdown) description on the main features of the project.
*/
public string $descAbout;
/**
* Rich-text (markdown) about why this project is important.
*/
public string $descGoal;
/**
* Rich-text (markdown) about team and previous experience.
*/
public string $descTeam;
/**
* Extracted embedding data from the Project's video.
+34 -38
View File
@@ -58,7 +58,7 @@ class ProjectsPump implements PumpInterface
}
$created = new \DateTime($record['created']);
if (\in_array($status, [ProjectStatus::InDraft]) && $created < new \DateTime('2025-01-01')) {
if (\in_array($status, [ProjectStatus::InDraft]) && $created < new \DateTime('2026-01-01')) {
return;
}
@@ -92,7 +92,10 @@ class ProjectsPump implements PumpInterface
$project->addLocale($record['lang']);
$project->setTitle($record['name'] ?? '');
$project->setSubtitle($record['subtitle'] ?? '');
$project->setDescription($this->getProjectDescription($record));
$project->setDescBrief($record['description']);
$project->setDescAbout($record['about']);
$project->setDescGoal($record['motivation']);
$project->setDescTeam($record['related']);
$this->setPreventFlushAndClear(true);
$this->persist($project, $context);
@@ -103,7 +106,10 @@ class ProjectsPump implements PumpInterface
$this->localize($project, $localizations, $context, [
'title' => fn($l) => $l['name'],
'subtitle' => fn($l) => $l['subtitle'],
'description' => fn($l) => $this->getProjectDescription($l),
'descBrief' => fn($l) => $l['description'],
'descAbout' => fn($l) => $l['about'],
'descGoal' => fn($l) => $l['motivation'],
'descTeam' => fn($l) => $l['related'],
]);
}
@@ -123,34 +129,6 @@ class ProjectsPump implements PumpInterface
return $this->userRepository->matching($criteria)->first() ?? null;
}
private function getProjectDescription(array $record): string
{
$lang = $record['lang'];
$hasTitles = \array_key_exists($lang, self::PROJECT_DESC_TITLES);
$description = $record['description'];
if ($hasTitles) {
$description .= \sprintf("\n\n## %s", self::PROJECT_DESC_TITLES[$lang]['about']);
}
$description .= \sprintf("\n%s", $record['about']);
if ($hasTitles) {
$description .= \sprintf("\n\n## %s", self::PROJECT_DESC_TITLES[$lang]['motivation']);
}
$description .= \sprintf("\n\n%s", $record['motivation']);
if ($hasTitles) {
$description .= \sprintf("\n\n## %s", self::PROJECT_DESC_TITLES[$lang]['related']);
}
$description .= \sprintf("\n%s", $record['related']);
return $description;
}
private function getProjectLocalizations(Project $project, array $context): array
{
$query = $this->getDbConnection($context)->prepare(
@@ -194,14 +172,30 @@ class ProjectsPump implements PumpInterface
private function getProjectTerritory(array $record): Territory
{
if ($record['project_location'] === null) {
$address = $record['project_location'];
if ($address === null) {
if ($record['address'] !== null) {
$address = $record['address'];
}
if ($record['location'] !== null) {
$address = $record['location'];
}
if ($record['address'] !== null && $record['location'] !== null) {
$address = \sprintf('%s, %s', $record['address'], $record['location']);
}
}
if ($address === null) {
return Territory::unknown();
}
$cleanAddress = $this->cleanLocation($record['project_location'], 2);
$cleanAddress = $this->cleanLocation($address, 2);
if ($cleanAddress === '') {
return Territory::unknown($record['project_location']);
return Territory::unknown($address);
}
return $this->territoryService->search($cleanAddress);
@@ -209,12 +203,14 @@ class ProjectsPump implements PumpInterface
private function getProjectVideoSource(array $record): ?string
{
if ($record['video'] !== null) {
return \trim($record['video']);
$video = \trim($record['video']);
if ($video !== '' && $record['video'] !== null) {
return $video;
}
if ($record['media'] !== null) {
return \trim($record['media']);
$media = \trim($record['media']);
if ($media !== '' && $record['media'] !== null) {
return $media;
}
return null;
+22 -2
View File
@@ -24,6 +24,7 @@ class ProjectUpdationDto
* Main headline for the Project.
*/
#[Assert\Regex('/[a-zA-Z]{1,}/')]
#[Assert\Length(min: 3)]
public string $title;
/**
@@ -47,9 +48,28 @@ class ProjectUpdationDto
public Territory $territory;
/**
* Free-form rich text description for the Project.
* Rich-text (markdown allowed) introduction to the project.
*/
public string $description;
#[Assert\Length(min: 20)]
public string $descBrief;
/**
* Rich-text (markdown allowed) description on the main features of the project.
*/
#[Assert\Length(min: 20)]
public string $descAbout;
/**
* Rich-text (markdown allowed) about why this project is important.
*/
#[Assert\Length(min: 20)]
public string $descGoal;
/**
* Rich-text (markdown allowed) about team and previous experience.
*/
#[Assert\Length(min: 20)]
public string $descTeam;
/**
* On `minimum`, Project will campaign until the minimum deadline.\
+14 -4
View File
@@ -15,8 +15,13 @@ class Category implements LocalizedInterface
use LocalizedTrait;
#[ORM\Id]
#[ORM\Column(length: 255)]
private ?string $id = null;
#[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()]
@@ -27,9 +32,14 @@ class Category implements LocalizedInterface
return $this->id;
}
public function setId(string $id): static
public function getSlug(): ?string
{
$this->id = $id;
return $this->slug;
}
public function setSlug(string $slug): static
{
$this->slug = $slug;
return $this;
}
+53 -8
View File
@@ -78,12 +78,21 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
#[ORM\Embedded(class: Territory::class)]
private ?Territory $territory;
/**
* The description body for the Project.
*/
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Gedmo\Translatable()]
private ?string $description = null;
private ?string $descBrief = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Gedmo\Translatable()]
private ?string $descAbout = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Gedmo\Translatable()]
private ?string $descGoal = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Gedmo\Translatable()]
private ?string $descTeam = null;
/**
* A video showcasing the Project.
@@ -277,14 +286,50 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
return $this;
}
public function getDescription(): ?string
public function getDescBrief(): ?string
{
return $this->description;
return $this->descBrief;
}
public function setDescription(string $description): static
public function setDescBrief(?string $descBrief): static
{
$this->description = $description;
$this->descBrief = $descBrief;
return $this;
}
public function getDescAbout(): ?string
{
return $this->descAbout;
}
public function setDescAbout(?string $descAbout): static
{
$this->descAbout = $descAbout;
return $this;
}
public function getDescGoal(): ?string
{
return $this->descGoal;
}
public function setDescGoal(?string $descGoal): static
{
$this->descGoal = $descGoal;
return $this;
}
public function getDescTeam(): ?string
{
return $this->descTeam;
}
public function setDescTeam(?string $descTeam): static
{
$this->descTeam = $descTeam;
return $this;
}
+1 -1
View File
@@ -40,7 +40,7 @@ class Territory
* Plain-text address.\
* e.g: Forn de l’Olivera 22B, 07012 Palma, Illes Balears.
*/
#[ORM\Column(type: Types::STRING, nullable: true)]
#[ORM\Column(type: Types::TEXT, nullable: true)]
public readonly ?string $address;
public function __construct(
-2
View File
@@ -16,11 +16,9 @@ final class CategoryFactory extends PersistentProxyObjectFactory
}
protected function defaults(
string $id = 'test',
string $name = 'Test Category',
): array|callable {
return [
'id' => $id,
'name' => $name,
];
}
+8 -2
View File
@@ -41,8 +41,11 @@ final class ProjectFactory extends PersistentProxyObjectFactory
{
return [
'deadline' => ProjectDeadline::Minimum,
'description' => '',
'territory' => new Territory('ES'),
'descBrief' => 'Brief information on this Project',
'descAbout' => 'About this Project',
'descGoal' => 'Project\'s goal',
'descTeam' => 'Project\'s team',
'owner' => new User(),
'status' => ProjectStatus::InDraft,
'subtitle' => 'Subtitle',
@@ -54,7 +57,10 @@ final class ProjectFactory extends PersistentProxyObjectFactory
{
return [
'deadline' => self::faker()->randomElement(ProjectDeadline::cases()),
'description' => self::faker()->text(),
'descBrief' => self::faker()->text(),
'descAbout' => self::faker()->text(),
'descGoal' => self::faker()->text(),
'descTeam' => self::faker()->text(),
'territory' => new Territory('ES'),
'owner' => UserFactory::createWithMode(),
'status' => self::faker()->randomElement(ProjectStatus::cases()),
+2 -2
View File
@@ -13,10 +13,10 @@ class NominatimService
private float $lastRequestTime = 0.0;
/**
* Cache calls to Nominatim for 1 hour.
* Cache calls to Nominatim for 24 hours.
* This ensures freshness of the data but minimizes bulk-operations hit rates to Nominatim.
*/
public const NOMINATIM_CACHE_TTL = 3600;
public const NOMINATIM_CACHE_TTL = 86400;
private HttpClientInterface $httpClient;
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\CategoryApiResource;
use App\Doctrine\LocalizedExtensionTrait;
use App\Mapping\AutoMapper;
use App\Repository\CategoryRepository;
class CategoryStateProvider implements ProviderInterface
{
use LocalizedExtensionTrait;
public function __construct(
private CategoryRepository $categoryRepository,
private AutoMapper $autoMapper,
) {}
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);
$query = $this->addLocalizationHints($queryBuilder, $this->getAcceptedLanguages($context));
$project = $query->getOneOrNullResult();
if ($project === null) {
return null;
}
return $this->autoMapper->map($project, CategoryApiResource::class);
}
}
+1 -1
View File
@@ -249,7 +249,7 @@ class GetAllTest extends ProjectTestCase
$partialDescription = 'Physically defined as a modulable space';
$this->testGetAllByParam(
'description',
'descBrief',
"lorem ipsum $partialDescription",
'lorem ipsum vitae'
);
-1
View File
@@ -21,7 +21,6 @@ class GetOneTest extends ProjectTestCase
'title' => $project->getTitle(),
'subtitle' => $project->getSubtitle(),
'territory' => ['country' => $project->getTerritory()->country],
'description' => $project->getDescription(),
'deadline' => $project->getDeadline()->value,
'status' => $project->getStatus()->value,
];
+1 -1
View File
@@ -26,7 +26,7 @@ abstract class ProjectTestCase extends ApiTestCase
{
self::bootKernel();
save(CategoryFactory::createOne(['id' => 'test']));
save(CategoryFactory::createOne(['name' => 'test']));
}
// Auxiliary Methods
+1 -1
View File
@@ -25,7 +25,7 @@ class UpdateTest extends ProjectTestCase
'headers' => $this->withAuthHeader(TestUser::get()),
'json' => [
'title' => 'New project title',
'description' => 'Updated project description',
'descBrief' => 'Updated Project\'s brief description',
],
]);
+10 -12
View File
@@ -6,14 +6,16 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectDeadline;
use App\Entity\Project\ProjectStatus;
use App\Entity\Territory;
use App\Entity\User\User;
use App\Factory\Project\ProjectFactory;
use Doctrine\ORM\EntityManagerInterface;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
class ProjectCalendarTest extends ApiTestCase
{
use ResetDatabase;
use Factories;
private EntityManagerInterface $entityManager;
private User $owner;
@@ -40,16 +42,10 @@ class ProjectCalendarTest extends ApiTestCase
private function createTestProject(ProjectDeadline $deadline = ProjectDeadline::Minimum): Project
{
$project = new Project();
$project->setTitle('Test Project');
$project->setSubtitle('Test Project Subtitle');
$project->setDeadline($deadline);
$project->setDescription('Test Project Description');
$project->setTerritory(new Territory('ES'));
$project->setOwner($this->owner);
$project->setStatus(ProjectStatus::InCampaignReview);
return $project;
return ProjectFactory::createOne([
'owner' => $this->owner,
'deadline' => $deadline,
])->_real();
}
private function createProjectAndSetToInCampaign(
@@ -104,6 +100,8 @@ class ProjectCalendarTest extends ApiTestCase
$this->entityManager->persist($project);
$this->entityManager->flush();
$this->assertNull($project->getCalendar());
$this->assertNull($project->getCalendar()->release);
$this->assertNull($project->getCalendar()->minimum);
$this->assertNull($project->getCalendar()->optimum);
}
}