From 322b608481c6bd1dae7381c3cb8199f9c9d0f6bf Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 22 May 2026 11:00:25 +0200 Subject: [PATCH 1/4] feat: add territory and description properties to Users --- migrations/Version20260522085831.php | 26 +++++++++++++++ src/ApiResource/User/UserApiResource.php | 41 +++++++++++++++++------- src/Entity/User/User.php | 31 ++++++++++++++++++ 3 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 migrations/Version20260522085831.php diff --git a/migrations/Version20260522085831.php b/migrations/Version20260522085831.php new file mode 100644 index 0000000..95c11ca --- /dev/null +++ b/migrations/Version20260522085831.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE user ADD description LONGTEXT DEFAULT NULL, ADD territory_country VARCHAR(255) DEFAULT NULL, ADD territory_sub_lvl1 VARCHAR(255) DEFAULT NULL, ADD territory_sub_lvl2 VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE user DROP description, DROP territory_country, DROP territory_sub_lvl1, DROP territory_sub_lvl2'); + } +} diff --git a/src/ApiResource/User/UserApiResource.php b/src/ApiResource/User/UserApiResource.php index a91e0f5..73fbc2e 100644 --- a/src/ApiResource/User/UserApiResource.php +++ b/src/ApiResource/User/UserApiResource.php @@ -9,6 +9,7 @@ use ApiPlatform\Metadata\Parameters; use ApiPlatform\Metadata\QueryParameter; use App\ApiResource\Accounting\AccountingApiResource; use App\Dto\UserSignupDto; +use App\Entity\Territory; use App\Entity\User\User; use App\Entity\User\UserType; use App\Filter\OrderedLikeFilter; @@ -55,12 +56,6 @@ class UserApiResource #[API\ApiProperty(writable: false, identifier: true)] public int $id; - #[Assert\NotBlank()] - #[Assert\Email()] - #[API\ApiFilter(SearchFilter::class, strategy: 'partial')] - #[API\ApiProperty(security: 'is_granted("USER_EDIT", object)')] - public string $email; - /** * A unique, non white space, byte-safe string identifier for this User. */ @@ -70,6 +65,18 @@ class UserApiResource #[API\ApiFilter(filterClass: OrderedLikeFilter::class)] public string $handle; + #[Assert\NotBlank()] + #[Assert\Email()] + #[API\ApiFilter(SearchFilter::class, strategy: 'partial')] + #[API\ApiProperty(security: 'is_granted("USER_EDIT", object)')] + public string $email; + + /** + * Has this User confirmed their email address? + */ + #[API\ApiProperty(writable: false, security: 'is_granted("USER_VIEW", object)')] + public bool $emailConfirmed; + /** * URL to the avatar image of this User. */ @@ -125,12 +132,6 @@ class UserApiResource #[API\ApiProperty(writable: false)] public array $projects; - /** - * Has this User confirmed their email address? - */ - #[API\ApiProperty(writable: false, security: 'is_granted("USER_VIEW", object)')] - public bool $emailConfirmed; - /** * A flag determined by the platform for Users who are known to be active. */ @@ -152,4 +153,20 @@ class UserApiResource { return \array_map(fn($value) => Link::tryFrom($value), $values); } + + /** + * ISO 3166 data about the Users's location territory. + */ + #[Assert\Valid()] + #[API\ApiFilter( + filterClass: SearchFilter::class, + strategy: 'exact', + properties: ['territory.country', 'territory.subLvl1', 'territory.subLvl2'] + )] + public Territory $territory; + + /** + * Free-form rich text description for the User. + */ + public string $description; } diff --git a/src/Entity/User/User.php b/src/Entity/User/User.php index b4ee235..d279ba5 100644 --- a/src/Entity/User/User.php +++ b/src/Entity/User/User.php @@ -9,6 +9,7 @@ use App\Entity\DateUpdatedTrait; use App\Entity\DedupedTrait; use App\Entity\MigratedTrait; use App\Entity\Project\Project; +use App\Entity\Territory; use App\Library\Link; use App\Mapping\Provider\EntityMapProvider; use App\Repository\User\UserRepository; @@ -117,6 +118,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account #[ORM\Column(nullable: true)] private ?array $links = null; + #[ORM\Embedded(class: Territory::class)] + private ?object $territory = null; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $description = null; + public function __construct() { $this->accounting = Accounting::of($this); @@ -362,4 +369,28 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Account return $this; } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): static + { + $this->description = $description; + + return $this; + } + + public function getTerritory(): ?object + { + return $this->territory; + } + + public function setTerritory(?object $territory): static + { + $this->territory = $territory; + + return $this; + } } From 09182d46412bfd8eb79e0a236867b89da8a5d8e9 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 22 May 2026 11:25:49 +0200 Subject: [PATCH 2/4] feat: extract territory pumping utilities to own trait --- src/Benzina/ProjectsPump.php | 3 +- src/Benzina/ProjectsPumpTrait.php | 123 --------------------------- src/Benzina/TerritoryPumpTrait.php | 130 +++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 124 deletions(-) create mode 100644 src/Benzina/TerritoryPumpTrait.php diff --git a/src/Benzina/ProjectsPump.php b/src/Benzina/ProjectsPump.php index b9d2a30..9337101 100644 --- a/src/Benzina/ProjectsPump.php +++ b/src/Benzina/ProjectsPump.php @@ -28,6 +28,7 @@ class ProjectsPump implements PumpInterface use DatabasePumpTrait; use ProjectsPumpTrait; use LocalizedPumpTrait; + use TerritoryPumpTrait; public function __construct( private ProjectRepository $projectRepository, @@ -197,7 +198,7 @@ class ProjectsPump implements PumpInterface return Territory::unknown(); } - $cleanAddress = $this->cleanProjectLocation($record['project_location'], 2); + $cleanAddress = $this->cleanLocation($record['project_location'], 2); if ($cleanAddress === '') { return Territory::unknown(); diff --git a/src/Benzina/ProjectsPumpTrait.php b/src/Benzina/ProjectsPumpTrait.php index 9bf33a1..1362143 100644 --- a/src/Benzina/ProjectsPumpTrait.php +++ b/src/Benzina/ProjectsPumpTrait.php @@ -4,129 +4,6 @@ namespace App\Benzina; trait ProjectsPumpTrait { - /** - * Normalizes flexible address strings to obtain highly cacheable and improved search queries. - * Based on analysis of the Goteo v3 `project.project_location` values. - * - * @param int $detailLevel Desired number of remaining components in output address - */ - public static function cleanProjectLocation(string $location, int $detailLevel = 3): string - { - // Skip web addresses - if ( - \str_starts_with($location, 'www.') - || \str_starts_with($location, 'http://') - || \str_starts_with($location, 'https://') - ) { - return ''; - } - - // Remove secondary conjoined places from locations - // e.g: "España y el mundo" -> "España" - foreach ([' / ', ' | ', ' - ', ' y ', ' i ', ' and ', ' & '] as $conjoinment) { - if (\str_contains($location, $conjoinment)) { - $location = \explode($conjoinment, $location)[0]; - } - } - - // Normalize parenthesis - if (\str_contains($location, '(') || \str_contains($location, ')')) { - $location = \str_replace('(', ',', $location); - $location = \str_replace(')', '', $location); - } - - // Remove colon specifications - // e.g: "Universidad Carlos III de Madrid: Campus de Getafe, Calle Madrid, Getafe, España" -> "Campus de Getafe, Calle Madrid, Getafe, España" - $location = \preg_replace('/^[\w ]+:/', '', $location); - - // Process comma-separated address pieces - $location = \explode(',', $location); - - // Clean non desired location pieces - $location = \array_filter($location, function ($l) { - if (empty($l)) { - return false; - } - - // Skip numeric only pieces: coordinates, street numbers, etc - if (\preg_match('/^[-\d.]*$/', $l) || \str_contains($l, 'º')) { - return false; - } - - return true; - }); - - // Normalize location pieces - $location = \array_map(function ($l) { - // Trim spaces and numbers at start of piece - $l = \preg_replace('/^[\d ]+/', '', \trim($l)); - - // Normalize typos and name variations - foreach (self::COMMON_VARIATIONS as $standard => $variations) { - if (in_array(\mb_strtoupper($l), $variations)) { - $l = $standard; - } - } - - return $l; - }, $location); - - $location = \join(', ', \array_slice($location, -1 * $detailLevel)); - - // Trim remaining numbers and punctuation marks - $location = \preg_replace('/^[\d\.\,\-;]+/', '', $location); - $location = \preg_replace('/[\d\.\,\-;]+$/', '', $location); - - return \mb_strtoupper(\trim($location)); - } - - /** - * @var array The standard preferred name and a list of possible variations and misspellings - */ - private const COMMON_VARIATIONS = [ - 'PERÚ' => ['PÉROU'], - 'ECUADOR' => ['EQUADOR'], - 'MÉXICO' => ['MESSICO', 'MX'], - 'CIUDAD DE MÉXICO' => ['CMDX'], - 'EUROPA' => ['EUROPEAN UNION'], - 'FRANCIA' => ['FRANCE'], - 'ITALIA' => ['ITALY'], - 'ALEMANIA' => ['GERMANY'], - 'FINLANDIA' => ['FINLAND'], - 'SUECIA' => ['SWEDEN', 'SVERIGE'], - 'ESPAÑA' => ['ESPANYA', 'ESPANHA', 'ESPAGNE', 'SPAGNA', 'SPANIEN', 'SPAIN', 'ESTADO ESPAÑOL', 'ESPAINIA'], - 'ANDALUCÍA' => ['ANDALUCIA', 'ANDALUSIA'], - 'CÁDIZ' => ['CADIZ'], - 'CÓRDOBA' => ['CORDOBA'], - 'COMUNIDAD VALENCIANA' => ['COMUNITAT VALENCIANA', 'PAÍS VALENCIÀ'], - 'VALENCIA' => ['VALÈNCIA', 'PROVINCIA DE VALENCIA'], - 'ALICANTE' => ['ALACANT'], - 'CASTELLÓN DE LA PLANA' => ['CASTELLÓ DE LA PLANA'], - 'LAS PALMAS DE GRAN CANARIA' => ['LAS PALMAS'], - 'CÁCERES' => ['CACERES'], - 'MÉRIDA' => ['MERIDA'], - 'LEÓN' => ['LEON'], - 'LEGANÉS' => ['LEGANES'], - 'BALEARES' => ['BALEAREN', 'ISLAS BALEARES', 'ILLES BALEARS', 'BALEARIC ISLANDS'], - 'GALICIA' => ['GALIZA'], - 'A CORUÑA' => ['LA CORUÑA'], - 'OURENSE' => ['ORENSE'], - 'CATALUÑA' => ['CATALUNYA', 'PAÏSOS CATALANS'], - 'LLEIDA' => ['LÉRIDA'], - 'TARRASA' => ['TERRASA'], - 'BARCELONA' => ['PROVINCIA DE BARCELONA', 'BARCELONE', 'A BARCELONA'], - 'HOSPITALET DE LLOBREGAT' => ["L'HOSPITALET DE LLOBREGAT"], - 'SANT ADRIÀ DE BESÒS' => ['SANT ADRIÀ DEL BESÒS'], - 'MURCIA' => ['REGIÓN DE MURCIA'], - 'PAÍS VASCO' => ['EUSKADI', 'EUSKAL HERRIA'], - 'BILBAO' => ['BILBO'], - 'SAN SEBASTIÁN' => ['DONOSTIA', 'DONOSTIA-SAN SEBASTIÁN'], - 'VIZCAYA' => ['BIZKAIA'], - 'IRÚN' => ['IRUN'], - 'NAVARRA' => ['NAVARRE', 'NAFARROA'], - 'PAMLONA' => ['IRUÑEA'], - ]; - private const PROJECT_DESC_TITLES = [ 'es' => [ 'about' => 'Descripción del proyecto. Características, fortalezas y diferenciales.', diff --git a/src/Benzina/TerritoryPumpTrait.php b/src/Benzina/TerritoryPumpTrait.php new file mode 100644 index 0000000..d2a4174 --- /dev/null +++ b/src/Benzina/TerritoryPumpTrait.php @@ -0,0 +1,130 @@ + "España" + foreach ([' / ', ' | ', ' - ', ' y ', ' i ', ' and ', ' & '] as $conjoinment) { + if (\str_contains($location, $conjoinment)) { + $location = \explode($conjoinment, $location)[0]; + } + } + + // Normalize parenthesis + if (\str_contains($location, '(') || \str_contains($location, ')')) { + $location = \str_replace('(', ',', $location); + $location = \str_replace(')', '', $location); + } + + // Remove colon specifications + // e.g: "Universidad Carlos III de Madrid: Campus de Getafe, Calle Madrid, Getafe, España" -> "Campus de Getafe, Calle Madrid, Getafe, España" + $location = \preg_replace('/^[\w ]+:/', '', $location); + + // Process comma-separated address pieces + $location = \explode(',', $location); + + // Clean non desired location pieces + $location = \array_filter($location, function ($l) { + if (empty($l)) { + return false; + } + + // Skip numeric only pieces: coordinates, street numbers, etc + if (\preg_match('/^[-\d.]*$/', $l) || \str_contains($l, 'º')) { + return false; + } + + return true; + }); + + // Normalize location pieces + $location = \array_map(function ($l) { + // Trim spaces and numbers at start of piece + $l = \preg_replace('/^[\d ]+/', '', \trim($l)); + + // Normalize typos and name variations + foreach (self::COMMON_VARIATIONS as $standard => $variations) { + if (in_array(\mb_strtoupper($l), $variations)) { + $l = $standard; + } + } + + return $l; + }, $location); + + $location = \join(', ', \array_slice($location, -1 * $detailLevel)); + + // Trim remaining numbers and punctuation marks + $location = \preg_replace('/^[\d\.\,\-;]+/', '', $location); + $location = \preg_replace('/[\d\.\,\-;]+$/', '', $location); + + return \mb_strtoupper(\trim($location)); + } + + /** + * @var array The standard preferred name and a list of possible variations and misspellings + */ + private const COMMON_VARIATIONS = [ + 'PERÚ' => ['PÉROU'], + 'ECUADOR' => ['EQUADOR'], + 'MÉXICO' => ['MESSICO', 'MX'], + 'CIUDAD DE MÉXICO' => ['CDMX'], + 'EUROPA' => ['EUROPEAN UNION', 'EUROPE'], + 'FRANCIA' => ['FRANCE'], + 'ITALIA' => ['ITALY'], + 'ALEMANIA' => ['GERMANY', 'DEUTSCHLAND'], + 'FINLANDIA' => ['FINLAND'], + 'SUECIA' => ['SWEDEN', 'SVERIGE'], + 'ESPAÑA' => ['ESPANYA', 'ESPANHA', 'ESPAGNE', 'SPAGNA', 'SPANIEN', 'SPAIN', 'ESTADO ESPAÑOL', 'ESPAINIA'], + 'ANDALUCÍA' => ['ANDALUCIA', 'ANDALUSIA'], + 'CÁDIZ' => ['CADIZ'], + 'CÓRDOBA' => ['CORDOBA'], + 'COMUNIDAD VALENCIANA' => ['COMUNITAT VALENCIANA', 'PAÍS VALENCIÀ'], + 'VALENCIA' => ['VALÈNCIA', 'PROVINCIA DE VALENCIA'], + 'ALICANTE' => ['ALACANT'], + 'CASTELLÓN DE LA PLANA' => ['CASTELLÓ DE LA PLANA', 'CASTELLON DE LA PLANA'], + 'LAS PALMAS DE GRAN CANARIA' => ['LAS PALMAS'], + 'CÁCERES' => ['CACERES'], + 'MÉRIDA' => ['MERIDA'], + 'LEÓN' => ['LEON'], + 'LEGANÉS' => ['LEGANES'], + 'BALEARES' => ['BALEAREN', 'BALEARS', 'ISLAS BALEARES', 'ILLES BALEARS', 'BALEARIC ISLANDS'], + 'GALICIA' => ['GALIZA'], + 'A CORUÑA' => ['LA CORUÑA', 'CORUÑA'], + 'OURENSE' => ['ORENSE'], + 'CATALUÑA' => ['CATALUNYA', 'PAÏSOS CATALANS', 'CATALUNA', 'CATALONIA'], + 'LLEIDA' => ['LÉRIDA', 'LERIDA'], + 'TARRASA' => ['TERRASA'], + 'BARCELONA' => ['PROVINCIA DE BARCELONA', 'BARCELONE', 'A BARCELONA'], + 'HOSPITALET DE LLOBREGAT' => ["L'HOSPITALET DE LLOBREGAT"], + 'SANT ADRIÀ DE BESÒS' => ['SANT ADRIÀ DEL BESÒS'], + 'MURCIA' => ['REGIÓN DE MURCIA'], + 'PAÍS VASCO' => ['EUSKADI', 'EUSKAL HERRIA'], + 'BILBAO' => ['BILBO'], + 'SAN SEBASTIÁN' => ['DONOSTIA', 'DONOSTIA-SAN SEBASTIÁN'], + 'VIZCAYA' => ['BIZKAIA'], + 'IRÚN' => ['IRUN'], + 'NAVARRA' => ['NAVARRE', 'NAFARROA'], + 'PAMLONA' => ['IRUÑEA'], + 'CANARIAS' => ['ISLAS CANARIAS', 'CANARY ISLANDS'], + ]; +} From 4533d09926294a432355a2e2b1807f1d77719146 Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 22 May 2026 11:28:16 +0200 Subject: [PATCH 3/4] feat: pump users description and territory data --- src/Benzina/UsersPump.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/Benzina/UsersPump.php b/src/Benzina/UsersPump.php index 73f2173..028cb98 100644 --- a/src/Benzina/UsersPump.php +++ b/src/Benzina/UsersPump.php @@ -2,11 +2,13 @@ namespace App\Benzina; +use App\Entity\Territory; use App\Entity\User\Organization; use App\Entity\User\Person; use App\Entity\User\User; use App\Entity\User\UserType; use App\Library\Link; +use App\Service\Project\TerritoryService; use App\Service\UserService; use Doctrine\Persistence\ManagerRegistry; use Goteo\Benzina\Pump\ArrayPumpTrait; @@ -20,9 +22,11 @@ class UsersPump implements PumpInterface use ArrayPumpTrait; use DoctrinePumpTrait; use UsersPumpTrait; + use TerritoryPumpTrait; public function __construct( private ManagerRegistry $managerRegistry, + private TerritoryService $territoryService, ) {} public function supports(mixed $sample): bool @@ -80,6 +84,8 @@ class UsersPump implements PumpInterface $user->setDateUpdated(new \DateTime()); $user->setType($this->getUserType($record)); $user->setLinks($this->getLinks($record)); + $user->setTerritory($this->getTerritory($record)); + $user->setDescription($record['about']); match ($user->getType()) { UserType::Individual => $user = $this->setUserPerson($record, $user), @@ -183,4 +189,19 @@ class UsersPump implements PumpInterface return $links; } + + private function getTerritory(array $record): Territory + { + if ($record['location'] === null) { + return Territory::unknown(); + } + + $cleanAddress = $this->cleanLocation($record['location'], 2); + + if ($cleanAddress === '') { + return Territory::unknown(); + } + + return $this->territoryService->search($cleanAddress); + } } From ce22ade8824b5797f7c9d832ab4af070e93584bc Mon Sep 17 00:00:00 2001 From: Daniel Subiabre Date: Fri, 22 May 2026 11:38:53 +0200 Subject: [PATCH 4/4] fix: test TerritoryPumpTrait --- ...aitTest.php => TerritoryPumpTraitTest.php} | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) rename tests/Benzina/{ProjectsPumpTraitTest.php => TerritoryPumpTraitTest.php} (83%) diff --git a/tests/Benzina/ProjectsPumpTraitTest.php b/tests/Benzina/TerritoryPumpTraitTest.php similarity index 83% rename from tests/Benzina/ProjectsPumpTraitTest.php rename to tests/Benzina/TerritoryPumpTraitTest.php index fa2265b..c92fda0 100644 --- a/tests/Benzina/ProjectsPumpTraitTest.php +++ b/tests/Benzina/TerritoryPumpTraitTest.php @@ -2,22 +2,22 @@ namespace App\Tests\Benzina; -use App\Benzina\ProjectsPumpTrait; +use App\Benzina\TerritoryPumpTrait; use PHPUnit\Framework\TestCase; -class ProjectsPumpTraitTest extends TestCase +class TerritoryPumpTraitTest extends TestCase { - /** @var ProjectsPumpTrait */ + /** @var TerritoryPumpTrait */ private $pump; public function setUp(): void { - $this->pump = $this->getMockForTrait(ProjectsPumpTrait::class); + $this->pump = $this->getMockForTrait(TerritoryPumpTrait::class); } public function testCapitalizesResult() { - $cleanAddress = $this->pump->cleanProjectLocation('Test Address'); + $cleanAddress = $this->pump->cleanLocation('Test Address'); $this->assertEquals('TEST ADDRESS', $cleanAddress); } @@ -27,7 +27,7 @@ class ProjectsPumpTraitTest extends TestCase */ public function testSkipsInternetAddresses($internetAddress) { - $this->assertEquals('', $this->pump->cleanProjectLocation($internetAddress)); + $this->assertEquals('', $this->pump->cleanLocation($internetAddress)); } public function provideInternetAddresses(): array @@ -46,7 +46,7 @@ class ProjectsPumpTraitTest extends TestCase */ public function testStripsConjoinedAddresses($conjoinedAddress, $finalAddress) { - $cleanAddress = $this->pump->cleanProjectLocation($conjoinedAddress); + $cleanAddress = $this->pump->cleanLocation($conjoinedAddress); $this->assertEquals($finalAddress, $cleanAddress); } @@ -67,7 +67,7 @@ class ProjectsPumpTraitTest extends TestCase */ public function testStripsColonSpecifiers($colonSpecified, $removedSpecifier) { - $cleanAddress = $this->pump->cleanProjectLocation($colonSpecified); + $cleanAddress = $this->pump->cleanLocation($colonSpecified); $this->assertStringNotContainsString($removedSpecifier, $cleanAddress); } @@ -86,7 +86,7 @@ class ProjectsPumpTraitTest extends TestCase */ public function testFixesBadPunctuations($badAddress, $fixedAddress) { - $cleanAddress = $this->pump->cleanProjectLocation($badAddress); + $cleanAddress = $this->pump->cleanLocation($badAddress); $this->assertEquals($fixedAddress, $cleanAddress); } @@ -105,7 +105,7 @@ class ProjectsPumpTraitTest extends TestCase */ public function testStandarisesCountry($variation, $standard) { - $cleanAddress = $this->pump->cleanProjectLocation($variation); + $cleanAddress = $this->pump->cleanLocation($variation); $this->assertEquals($standard, $cleanAddress); }