Merge pull request #209 from goteo/feat/add-user-properties-for-user-page

feat: add properties for User page
This commit is contained in:
2026-05-22 11:49:08 +02:00
committed by GitHub
8 changed files with 249 additions and 146 deletions
+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 Version20260522085831 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add territory and description to Users';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+29 -12
View File
@@ -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;
}
+2 -1
View File
@@ -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();
-123
View File
@@ -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<string, 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.',
+130
View File
@@ -0,0 +1,130 @@
<?php
namespace App\Benzina;
trait TerritoryPumpTrait
{
/**
* 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 cleanLocation(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<string, 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'],
];
}
+21
View File
@@ -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);
}
}
+31
View File
@@ -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;
}
}
@@ -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);
}