Merge branch 'main' into feat/dtf-api

This commit is contained in:
2026-10-08 16:30:31 +02:00
49 changed files with 1664 additions and 113 deletions
+4
View File
@@ -18,6 +18,9 @@
APP_ENV=dev
APP_SECRET=b8ec44a08e5f4ba917879713a4a8e63c
APP_LOCALE='en'
# DO NOT LEAVE OPEN UNLESS YOU'VE GUARANTEED THAT TRAFFIC WILL ONLY COME FROM TRUSTED ORIGINS
# https://symfony.com/doc/6.4/deployment/proxies.html
APP_TRUSTED_PROXIES='127.0.0.1,REMOTE_ADDR'
###< symfony/framework-bundle ###
###> doctrine/doctrine-bundle ###
@@ -69,6 +72,7 @@ OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/jwt/private.pem
OAUTH_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
OAUTH_PASSPHRASE=df7ec1f514c44825926e3dabaf34e3f5
OAUTH_ENCRYPTION_KEY=325b6ce98c009bb83e885657ec08d142
OAUTH_ACCESS_TOKEN_TTL=PT1H
###< league/oauth2-server-bundle ###
###> knpuniversity/oauth2-client-bundle ###
+2
View File
@@ -28,6 +28,8 @@ framework:
headers:
'User-Agent': 'DTF Goteo v4 API'
trusted_proxies: '%env(APP_TRUSTED_PROXIES)%'
when@test:
framework:
test: true
@@ -2,6 +2,7 @@ league_oauth2_server:
client:
classname: App\Entity\OAuth2Client
authorization_server:
access_token_ttl: '%env(OAUTH_ACCESS_TOKEN_TTL)%'
private_key: '%env(resolve:OAUTH_PRIVATE_KEY)%'
private_key_passphrase: '%env(resolve:OAUTH_PASSPHRASE)%'
encryption_key: '%env(resolve:OAUTH_ENCRYPTION_KEY)%'
+1
View File
@@ -4,6 +4,7 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
env(OAUTH_ACCESS_TOKEN_TTL): PT1H
services:
# default configuration for services in *this* file
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261006114714 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add review, review_area and review_comment tables';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE review (id INT AUTO_INCREMENT NOT NULL, project_id INT NOT NULL, reviewer_id INT DEFAULT NULL, type VARCHAR(255) NOT NULL, date_created DATETIME NOT NULL, date_updated DATETIME NOT NULL, INDEX IDX_794381C6166D1F9C (project_id), INDEX IDX_794381C670574616 (reviewer_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE review_area (id INT AUTO_INCREMENT NOT NULL, review_id INT NOT NULL, title VARCHAR(255) NOT NULL, summary LONGTEXT DEFAULT NULL, risk VARCHAR(255) NOT NULL, date_created DATETIME NOT NULL, date_updated DATETIME NOT NULL, INDEX IDX_352070703E2E969B (review_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE review_comment (id INT AUTO_INCREMENT NOT NULL, author_id INT DEFAULT NULL, area_id INT NOT NULL, body LONGTEXT NOT NULL, date_created DATETIME NOT NULL, date_updated DATETIME NOT NULL, INDEX IDX_F9AE69BF675F31B (author_id), INDEX IDX_F9AE69BBD0F409C (area_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE review ADD CONSTRAINT FK_794381C6166D1F9C FOREIGN KEY (project_id) REFERENCES project (id)');
$this->addSql('ALTER TABLE review ADD CONSTRAINT FK_794381C670574616 FOREIGN KEY (reviewer_id) REFERENCES user (id)');
$this->addSql('ALTER TABLE review_area ADD CONSTRAINT FK_352070703E2E969B FOREIGN KEY (review_id) REFERENCES review (id)');
$this->addSql('ALTER TABLE review_comment ADD CONSTRAINT FK_F9AE69BF675F31B FOREIGN KEY (author_id) REFERENCES user (id)');
$this->addSql('ALTER TABLE review_comment ADD CONSTRAINT FK_F9AE69BBD0F409C FOREIGN KEY (area_id) REFERENCES review_area (id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE review DROP FOREIGN KEY FK_794381C6166D1F9C');
$this->addSql('ALTER TABLE review DROP FOREIGN KEY FK_794381C670574616');
$this->addSql('ALTER TABLE review_area DROP FOREIGN KEY FK_352070703E2E969B');
$this->addSql('ALTER TABLE review_comment DROP FOREIGN KEY FK_F9AE69BF675F31B');
$this->addSql('ALTER TABLE review_comment DROP FOREIGN KEY FK_F9AE69BBD0F409C');
$this->addSql('DROP TABLE review');
$this->addSql('DROP TABLE review_area');
$this->addSql('DROP TABLE review_comment');
}
}
+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.
*/
@@ -0,0 +1,57 @@
<?php
namespace App\ApiResource\Project;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\TimestampedCreationApiResource;
use App\ApiResource\TimestampedUpdationApiResource;
use App\ApiResource\User\UserApiResource;
use App\Entity\Project\Review;
use App\Entity\Project\ReviewType;
use App\State\ApiResourceStateProcessor;
use App\State\ApiResourceStateProvider;
/**
* ProjectReviews are launched as health-checks for Projects.\
* \
* The reviews are focused on certain aspects of a Project's lifecycle. The types `campaign` and `financial` reviews
* are to evaluate the fitness of a Project before being greenlit for campaigning or the legitimacy of their raised funds, respectively.\
* \
* ProjectReview resources cannot be manually created, they are created automatically when a Project moves into a "to review" status:
* - `to_campaign_review`: will launch a related ProjectReview of `campaign` type
* - `to_financial_review`: will launch a related ProjectReview of `financial` type.
*/
#[API\ApiResource(
shortName: 'ProjectReview',
stateOptions: new Options(entityClass: Review::class),
provider: ApiResourceStateProvider::class,
processor: ApiResourceStateProcessor::class
)]
#[API\GetCollection()]
#[API\Get()]
#[API\Delete()]
#[API\Patch()]
class ReviewApiResource
{
use TimestampedCreationApiResource;
use TimestampedUpdationApiResource;
#[API\ApiProperty(identifier: true, writable: false)]
public int $id;
#[API\ApiFilter(SearchFilter::class, strategy: 'exact')]
public ProjectApiResource $project;
#[API\ApiFilter(SearchFilter::class, strategy: 'exact')]
public UserApiResource $reviewer;
#[API\ApiFilter(SearchFilter::class, strategy: 'exact')]
public ReviewType $type;
/**
* @var ReviewAreaApiResource[]
*/
public array $areas;
}
@@ -0,0 +1,54 @@
<?php
namespace App\ApiResource\Project;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\TimestampedCreationApiResource;
use App\ApiResource\TimestampedUpdationApiResource;
use App\Entity\Project\ReviewArea;
use App\Entity\Project\ReviewAreaRisk;
use App\State\ApiResourceStateProcessor;
use App\State\ApiResourceStateProvider;
/**
* A ProjectReviewArea represents one specific topic of evaluation for ProjectReviews.\
* \
* Conversations, feedback and evolution of the ProjectReview must happen around specific areas of review.\
* Each area holds an associated risk, which represents the trust the reviewer has for the reviewed Project's health in that area,
* areas can only be updated by the assigned reviewer or an user with `ROLE_ADMIN`.
*/
#[API\ApiResource(
shortName: 'ProjectReviewArea',
stateOptions: new Options(entityClass: ReviewArea::class),
provider: ApiResourceStateProvider::class,
processor: ApiResourceStateProcessor::class,
)]
#[API\GetCollection()]
#[API\Post(security: 'is_granted("ROLE_ADMIN")')]
#[API\Get(security: 'is_granted("REVIEWAREA_VIEW", object)')]
#[API\Patch(security: 'is_granted("REVIEWAREA_EDIT", object)')]
#[API\Delete(security: 'is_granted("REVIEWAREA_EDIT", object)')]
class ReviewAreaApiResource
{
use TimestampedCreationApiResource;
use TimestampedUpdationApiResource;
#[API\ApiProperty(identifier: true, writable: false)]
public int $id;
#[API\ApiFilter(SearchFilter::class, strategy: 'exact')]
public ReviewApiResource $review;
public string $title;
public string $summary;
public ReviewAreaRisk $risk;
/**
* @var ReviewCommentApiResource[]
*/
public array $comments;
}
@@ -0,0 +1,50 @@
<?php
namespace App\ApiResource\Project;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata as API;
use App\ApiResource\TimestampedCreationApiResource;
use App\ApiResource\TimestampedUpdationApiResource;
use App\ApiResource\User\UserApiResource;
use App\Entity\Project\ReviewComment;
use App\State\ApiResourceStateProcessor;
use App\State\ApiResourceStateProvider;
use App\Validator\UserOwned;
use Symfony\Component\Validator\Constraints as Assert;
/**
* ProjectReviewComments hold the conversation between the reviewer and the reviewed Project owner.
*/
#[API\ApiResource(
shortName: 'ProjectReviewComment',
stateOptions: new Options(entityClass: ReviewComment::class),
provider: ApiResourceStateProvider::class,
processor: ApiResourceStateProcessor::class,
)]
#[API\GetCollection()]
#[API\Post(securityPostDenormalize: 'is_granted("REVIEWAREA_VIEW", object.area)')]
#[API\Get(security: 'is_granted("REVIEWAREA_VIEW", object.area)')]
#[API\Delete(security: 'is_granted("REVIEWAREA_VIEW", object.area)')]
class ReviewCommentApiResource
{
use TimestampedCreationApiResource;
use TimestampedUpdationApiResource;
#[API\ApiProperty(identifier: true, writable: false)]
public int $id;
#[Assert\NotBlank()]
#[API\ApiFilter(SearchFilter::class, strategy: 'exact')]
public ReviewAreaApiResource $area;
#[UserOwned()]
#[Assert\NotBlank()]
#[API\ApiFilter(SearchFilter::class, strategy: 'exact')]
public UserApiResource $author;
#[Assert\NotBlank()]
#[Assert\Length(min: 15)]
public string $body;
}
+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;
}
@@ -42,12 +42,13 @@ class OrganizationApiResource
public UserApiResource $user;
/**
* ID for tax purposes. e.g: NIF (formerly CIF), Umsatzsteuer-Id, EID, etc.
* Organization ID for tax purposes. e.g: NIF (formerly CIF), Umsatzsteuer-Id, EID, etc.
*/
#[Assert\NotBlank()]
#[Assert\Length(min: 2)]
#[API\ApiProperty(
securityPostDenormalize: 'is_granted("ORGANIZATION_EDIT", previous_object)',
)]
#[Assert\NotBlank()]
public string $taxId;
/**
@@ -55,10 +56,11 @@ class OrganizationApiResource
* as it appears on legal documents issued by or for this organization.\
* Will be used as last option for the display name of the User.
*/
#[Assert\NotBlank()]
#[Assert\Length(min: 1)]
#[API\ApiProperty(
securityPostDenormalize: 'is_granted("ORGANIZATION_EDIT", previous_object)',
)]
#[Assert\NotBlank()]
public string $legalName;
/**
@@ -66,6 +68,7 @@ class OrganizationApiResource
* Might be similar to the legal name or completely different.\
* Will be used as display name for the User when present.
*/
#[Assert\Length(min: 1)]
#[API\ApiProperty(
securityPostDenormalize: 'is_granted("ORGANIZATION_EDIT", previous_object)',
)]
+5 -1
View File
@@ -7,6 +7,7 @@ use ApiPlatform\Metadata as API;
use App\Entity\User\Person;
use App\State\ApiResourceStateProcessor;
use App\State\ApiResourceStateProvider;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Most times a real person is behind a User account,
@@ -43,6 +44,7 @@ class PersonApiResource
/**
* Personal ID for tax purposes. e.g: NIF, Steuer-ID, SSN, ITIN, etc.
*/
#[Assert\Length(min: 2)]
#[API\ApiProperty(
securityPostDenormalize: 'is_granted("PERSON_EDIT", previous_object)',
)]
@@ -52,6 +54,7 @@ class PersonApiResource
* First-part of the name of the person,
* in most western conventions this is the given name(s). e.g: John, Juan, etc.
*/
#[Assert\Length(min: 1)]
#[API\ApiProperty(
securityPostDenormalize: 'is_granted("PERSON_EDIT", previous_object)',
)]
@@ -59,8 +62,9 @@ class PersonApiResource
/**
* Last-part of the name of the person,
* in most western conventions this is the family name(s). e.g: Smith, Herrera García, etc.
* in most western conventions this is the family name(s). e.g: Smith, Herrera, etc.
*/
#[Assert\Length(min: 1)]
#[API\ApiProperty(
securityPostDenormalize: 'is_granted("PERSON_EDIT", previous_object)',
)]
+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 const SELECTED_PROJECTS = [
'la-nuestra-despega',
@@ -129,6 +130,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);
@@ -430,4 +436,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.
*/
+73
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;
@@ -161,6 +162,12 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
private Collection $collaborations;
/**
* @var Collection<int, Review>
*/
#[ORM\OneToMany(targetEntity: Review::class, mappedBy: 'project', cascade: ['persist'])]
private Collection $reviews;
/*
* A list of URLs provided by the Project owner.\
* e.g: social profiles, project website.
*
@@ -169,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);
@@ -179,6 +192,8 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
$this->supports = new ArrayCollection();
$this->categories = new ArrayCollection();
$this->collaborations = new ArrayCollection();
$this->reviews = new ArrayCollection();
$this->themes = new ArrayCollection();
}
public function getId(): ?int
@@ -378,6 +393,10 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
return $this->status;
}
/**
* DO NOT call this method unless you know what you are doing:
* Instead use `ProjectService::transition` or you WILL miss side-effects.
*/
public function setStatus(ProjectStatus $status): static
{
$this->status = $status;
@@ -594,6 +613,36 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
}
/**
* @return Collection<int, Review>
*/
public function getReviews(): Collection
{
return $this->reviews;
}
public function addReview(Review $review): static
{
if (!$this->reviews->contains($review)) {
$this->reviews->add($review);
$review->setProject($this);
}
return $this;
}
public function removeReview(Review $review): static
{
if ($this->reviews->removeElement($review)) {
// set the owning side to null (unless already changed)
if ($review->getProject() === $this) {
$review->setProject(null);
}
}
return $this;
}
/*
* @return Link[]
*/
public function getLinks(): ?array
@@ -610,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;
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
namespace App\Entity\Project;
use App\Entity\DateCreatedTrait;
use App\Entity\DateUpdatedTrait;
use App\Entity\User\User;
use App\Mapping\Provider\EntityMapProvider;
use App\Repository\Project\ReviewRepository;
use AutoMapper\Attribute\MapProvider;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
#[MapProvider(EntityMapProvider::class)]
#[ORM\Entity(repositoryClass: ReviewRepository::class)]
class Review
{
use DateCreatedTrait;
use DateUpdatedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'reviews')]
#[ORM\JoinColumn(nullable: false)]
private ?Project $project = null;
#[ORM\ManyToOne]
private ?User $reviewer = null;
#[ORM\Column(enumType: ReviewType::class)]
private ?ReviewType $type = null;
/**
* @var Collection<int, ReviewArea>
*/
#[ORM\OneToMany(targetEntity: ReviewArea::class, mappedBy: 'review', cascade: ['persist'])]
private Collection $areas;
public function __construct()
{
$this->areas = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getProject(): ?Project
{
return $this->project;
}
public function setProject(?Project $project): static
{
$this->project = $project;
return $this;
}
public function getReviewer(): ?User
{
return $this->reviewer;
}
public function isReviewedBy(UserInterface $user): bool
{
return $this->reviewer?->getUserIdentifier() === $user->getUserIdentifier();
}
public function setReviewer(?User $reviewer): static
{
$this->reviewer = $reviewer;
return $this;
}
public function getType(): ?ReviewType
{
return $this->type;
}
public function setType(ReviewType $type): static
{
$this->type = $type;
return $this;
}
/**
* @return Collection<int, ReviewArea>
*/
public function getAreas(): Collection
{
return $this->areas;
}
public function addArea(ReviewArea $area): static
{
if (!$this->areas->contains($area)) {
$this->areas->add($area);
$area->setReview($this);
}
return $this;
}
public function removeArea(ReviewArea $area): static
{
if ($this->areas->removeElement($area)) {
// set the owning side to null (unless already changed)
if ($area->getReview() === $this) {
$area->setReview(null);
}
}
return $this;
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace App\Entity\Project;
use App\Entity\DateCreatedTrait;
use App\Entity\DateUpdatedTrait;
use App\Mapping\Provider\EntityMapProvider;
use App\Repository\Project\ReviewAreaRepository;
use AutoMapper\Attribute\MapProvider;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[MapProvider(EntityMapProvider::class)]
#[ORM\Entity(repositoryClass: ReviewAreaRepository::class)]
class ReviewArea
{
use DateCreatedTrait;
use DateUpdatedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'areas')]
#[ORM\JoinColumn(nullable: false)]
private ?Review $review = null;
#[ORM\Column(length: 255)]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $summary = null;
#[ORM\Column(enumType: ReviewAreaRisk::class)]
private ReviewAreaRisk $risk = ReviewAreaRisk::High;
/**
* @var Collection<int, ReviewComment>
*/
#[ORM\OneToMany(targetEntity: ReviewComment::class, mappedBy: 'area')]
private Collection $comments;
public function __construct()
{
$this->comments = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getReview(): ?Review
{
return $this->review;
}
public function setReview(?Review $review): static
{
$this->review = $review;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getSummary(): ?string
{
return $this->summary;
}
public function setSummary(string $summary): static
{
$this->summary = $summary;
return $this;
}
public function getRisk(): ?ReviewAreaRisk
{
return $this->risk;
}
public function setRisk(?ReviewAreaRisk $risk): static
{
$this->risk = $risk;
return $this;
}
/**
* @return Collection<int, ReviewComment>
*/
public function getComments(): Collection
{
return $this->comments;
}
public function addComment(ReviewComment $comment): static
{
if (!$this->comments->contains($comment)) {
$this->comments->add($comment);
$comment->setArea($this);
}
return $this;
}
public function removeComment(ReviewComment $comment): static
{
if ($this->comments->removeElement($comment)) {
// set the owning side to null (unless already changed)
if ($comment->getArea() === $this) {
$comment->setArea(null);
}
}
return $this;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Entity\Project;
enum ReviewAreaRisk: string
{
/**
* The risk acceptable or none. Green light.
*/
case Low = 'low';
/**
* The risk is moderate or somewhat worrying. Yellow light.
*/
case Mid = 'mid';
/**
* The risk is severe and/or worrying. Red light.
*/
case High = 'high';
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace App\Entity\Project;
use App\Entity\DateCreatedTrait;
use App\Entity\DateUpdatedTrait;
use App\Entity\User\User;
use App\Mapping\Provider\EntityMapProvider;
use App\Repository\Project\ReviewCommentRepository;
use AutoMapper\Attribute\MapProvider;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[MapProvider(EntityMapProvider::class)]
#[ORM\Entity(repositoryClass: ReviewCommentRepository::class)]
class ReviewComment
{
use DateCreatedTrait;
use DateUpdatedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne]
private ?User $author = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $body = null;
#[ORM\ManyToOne(inversedBy: 'comments')]
#[ORM\JoinColumn(nullable: false)]
private ?ReviewArea $area = null;
public function getId(): ?int
{
return $this->id;
}
public function getAuthor(): ?User
{
return $this->author;
}
public function setAuthor(?User $author): static
{
$this->author = $author;
return $this;
}
public function getBody(): ?string
{
return $this->body;
}
public function setBody(string $body): static
{
$this->body = $body;
return $this;
}
public function getArea(): ?ReviewArea
{
return $this->area;
}
public function setArea(?ReviewArea $area): static
{
$this->area = $area;
return $this;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Entity\Project;
enum ReviewType: string
{
/**
* The goal is to determine if a Project is fit for campaigning or not.
*/
case Campaign = 'campaign';
/**
* The goal is to determine if a Project is fit for payment or not.
*/
case Financial = 'financial';
}
+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;
}
}
-37
View File
@@ -1,37 +0,0 @@
<?php
namespace App\EventListener;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectStatus;
use App\Service\Project\CalendarService;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
#[AsEntityListener(
event: Events::preUpdate,
method: 'preUpdate',
entity: Project::class
)]
final class ProjectsListener
{
public function __construct(
private CalendarService $calendarService,
) {}
public function preUpdate(
Project $project,
PreUpdateEventArgs $event,
) {
if (!$event->hasChangedField('status')) {
return;
}
if ($project->getStatus() === ProjectStatus::InCampaign) {
$calendar = $this->calendarService->makeCalendar($project->getDeadline());
$project->setCalendar($calendar);
}
}
}
+9
View File
@@ -31,6 +31,9 @@ trait RedocExtensionsTrait
'name' => 'Projects',
'tags' => [
'Project',
'ProjectReview',
'ProjectReviewArea',
'ProjectReviewComment',
'ProjectReward',
'ProjectRewardClaim',
'ProjectBudgetItem',
@@ -38,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
// */
@@ -0,0 +1,43 @@
<?php
namespace App\Repository\Project;
use App\Entity\Project\ReviewArea;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ReviewArea>
*/
class ReviewAreaRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ReviewArea::class);
}
// /**
// * @return ReviewArea[] Returns an array of ReviewArea objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('r')
// ->andWhere('r.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('r.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?ReviewArea
// {
// return $this->createQueryBuilder('r')
// ->andWhere('r.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}
@@ -0,0 +1,43 @@
<?php
namespace App\Repository\Project;
use App\Entity\Project\ReviewComment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ReviewComment>
*/
class ReviewCommentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ReviewComment::class);
}
// /**
// * @return ReviewComment[] Returns an array of ReviewComment objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('r')
// ->andWhere('r.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('r.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?ReviewComment
// {
// return $this->createQueryBuilder('r')
// ->andWhere('r.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}
@@ -0,0 +1,43 @@
<?php
namespace App\Repository\Project;
use App\Entity\Project\Review;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Review>
*/
class ReviewRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Review::class);
}
// /**
// * @return Review[] Returns an array of Review objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('r')
// ->andWhere('r.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('r.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?Review
// {
// return $this->createQueryBuilder('r')
// ->andWhere('r.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}
+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()
// ;
// }
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Security\Voter;
use App\ApiResource\Project\ReviewAreaApiResource;
use App\Entity\Project\ReviewArea;
use App\Entity\User\User;
use App\Mapping\AutoMapper;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class ReviewAreaVoter extends Voter
{
use UserOwnedVoterTrait;
public const EDIT = 'REVIEWAREA_EDIT';
public const VIEW = 'REVIEWAREA_VIEW';
public function __construct(
private AutoMapper $mapper,
) {}
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT, self::VIEW])
&& $subject instanceof ReviewAreaApiResource;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
if ($user->hasRoles(['ROLE_ADMIN'])) {
return true;
}
/** @var ReviewArea */
$area = $this->mapper->map($subject, ReviewArea::class);
if (!$area || !$area instanceof ReviewArea) {
return false;
}
$project = $area->getReview()->getProject();
switch ($attribute) {
case self::EDIT:
return $area->getReview()->isReviewedBy($user);
case self::VIEW:
return $this->isOwnerOf($project, $user);
}
return false;
}
}
+1 -1
View File
@@ -32,6 +32,6 @@ class UserOwnedVoter extends Voter
*/
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
return $this->isOwnerOf($subject, $token->getUser());
return $subject !== null && $this->isOwnerOf($subject, $token->getUser());
}
}
+36
View File
@@ -4,6 +4,7 @@ namespace App\Service\Project;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectStatus;
use App\Entity\Project\ReviewType;
use App\Entity\User\User;
use App\Security\Voter\UserOwnedVoterTrait;
@@ -19,6 +20,11 @@ class ProjectService
ProjectStatus::ToFunding->value => [ProjectStatus::InFunding],
];
public function __construct(
private ReviewService $reviewService,
private CalendarService $calendarService,
) {}
/**
* Check if an User can move a Project to some status.
*
@@ -47,4 +53,34 @@ class ProjectService
return false;
}
/**
* Moves a Project to a given ProjectStatus.
*
* @param ProjectStatus $to The new status to where the Project will move
*
* @return Project The resulting Project
*/
public function transition(Project $project, ProjectStatus $to): Project
{
$from = $project->getStatus();
if ($from === $to) {
return $project;
}
$project->setStatus($to);
return match ([$from, $to]) {
[ProjectStatus::InDraft, ProjectStatus::ToCampaignReview] => $project->addReview(
$this->reviewService->makeReview(ReviewType::Campaign)
),
[ProjectStatus::ToCampaign, ProjectStatus::InCampaign] => $project->setCalendar(
$this->calendarService->makeCalendar($project->getDeadline())
),
default => $project,
};
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Service\Project;
use App\Entity\Project\Review;
use App\Entity\Project\ReviewArea;
use App\Entity\Project\ReviewType;
class ReviewService
{
public function makeReview(ReviewType $type): Review
{
$review = new Review();
$review->setType($type);
/** @var ReviewArea[] */
$areas = match ($type) {
ReviewType::Campaign => $this->getCampaignReviewAreas(),
ReviewType::Financial => $this->getFinancialReviewAreas(),
};
foreach ($areas as $area) {
$review->addArea($area);
}
return $review;
}
/**
* @return ReviewArea[]
*/
private function getCampaignReviewAreas(): array
{
$config = new ReviewArea();
$config->setTitle('configuration');
$info = new ReviewArea();
$info->setTitle('info');
$rewards = new ReviewArea();
$rewards->setTitle('rewards');
$collabs = new ReviewArea();
$collabs->setTitle('collaborations');
$budget = new ReviewArea();
$budget->setTitle('budget');
$about = new ReviewArea();
$about->setTitle('about');
return [
$config,
$info,
$rewards,
$collabs,
$budget,
$about,
];
}
/**
* @return ReviewArea[]
*
* @todo Add financial review areas
*/
private function getFinancialReviewAreas(): array
{
return [];
}
}
+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);
}
}
+16 -19
View File
@@ -10,8 +10,8 @@ use App\ApiResource\Project\ProjectApiResource;
use App\Dto\ProjectCreationDto;
use App\Dto\ProjectUpdationDto;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectCalendar;
use App\Mapping\AutoMapper;
use App\Service\Project\CalendarService;
use App\Service\Project\ProjectService;
use App\State\EntityStateProcessor;
use Symfony\Bundle\SecurityBundle\Security;
@@ -24,6 +24,7 @@ class ProjectStateProcessor implements ProcessorInterface
private AutoMapper $autoMapper,
private Security $security,
private ProjectService $projectService,
private CalendarService $calendarService,
) {}
/**
@@ -33,13 +34,11 @@ class ProjectStateProcessor implements ProcessorInterface
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
if ($data instanceof ProjectCreationDto) {
$project = $this->getProjectFromCreation($data);
} elseif ($data instanceof ProjectUpdationDto) {
$project = $this->getProjectFromUpdate($data, $context);
} else {
throw new \InvalidArgumentException('Unsupported input for Project resource');
}
$project = match ($data::class) {
ProjectCreationDto::class => $this->getProjectFromCreation($data),
ProjectUpdationDto::class => $this->getProjectFromUpdate($data, $context),
default => throw new \InvalidArgumentException('Unsupported input for Project resource'),
};
$project = $this->entityStateProcessor->process($project, $operation, $uriVariables, $context);
@@ -60,7 +59,6 @@ class ProjectStateProcessor implements ProcessorInterface
$project = $this->autoMapper->map($data, Project::class);
$owner = $this->security->getUser();
if (!$owner) {
throw new AuthenticationException();
}
@@ -68,28 +66,25 @@ class ProjectStateProcessor implements ProcessorInterface
$project->setOwner($owner);
if (!isset($data->calendar->release)) {
$data->calendar->release = new \DateTimeImmutable('+28 days');
$project->setCalendar($this->calendarService->makeCalendar(
$project->getDeadline(),
new \DateTimeImmutable('+28 days')
));
}
$calendar = new ProjectCalendar();
$calendar->release = $data->calendar->release;
$project->setCalendar($calendar);
return $project;
}
private function getProjectFromUpdate(ProjectUpdationDto $data, array $context): Project
{
/** @var Project */
$project = $this->autoMapper->map($context['previous_data'], Project::class);
$actor = $this->security->getUser();
if (!$actor) {
throw new AuthenticationException();
}
/** @var Project */
$project = $this->autoMapper->map($context['previous_data'], Project::class);
if (isset($data->status) && $data->status !== $project->getStatus()) {
if (!$this->projectService->canTransition($actor, $project, $data->status)) {
throw new AccessDeniedException(\sprintf(
@@ -98,6 +93,8 @@ class ProjectStateProcessor implements ProcessorInterface
$data->status->value
));
}
$project = $this->projectService->transition($project, $data->status);
}
return $this->autoMapper->map($data, $project);
+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);
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
final class UserOwned extends Constraint
{
public string $message = 'Tried to reference "{{ value }}" while not authenticated as the very User.';
public function __construct(
public string $mode = 'strict',
?array $groups = null,
mixed $payload = null,
) {
parent::__construct([], $groups, $payload);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Validator;
use ApiPlatform\Metadata\IriConverterInterface;
use App\ApiResource\User\UserApiResource;
use App\Mapping\AutoMapper;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
final class UserOwnedValidator extends ConstraintValidator
{
public function __construct(
private Security $security,
private AutoMapper $mapper,
private IriConverterInterface $iriConverter,
) {}
/**
* @param UserOwned $constraint
*/
public function validate(mixed $value, Constraint $constraint): void
{
if ($value === null || $value === '') {
return;
}
$value = $this->iriConverter->getIriFromResource($value);
$user = $this->security->getUser();
if ($value && $user) {
/** @var UserApiResource */
$resource = $this->mapper->map($user, UserApiResource::class);
$resourceIri = $this->iriConverter->getIriFromResource($resource);
if ($value === $resourceIri) {
return;
}
}
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $value)
->addViolation()
;
}
}
+1 -1
View File
@@ -47,7 +47,7 @@
<li>
<div class="bg-white border border-grey rounded-2xl p-4 shadow-sm">
<a href="{{ path('oauth_providers_start', { provider: provider }) }}">
<img class="max-h-12 h-full" src="/oauth/providers/{{ provider }}.png" alt={{ provider }}>
<img class="max-h-12 h-full" src="/oauth2/providers/{{ provider }}.png" alt={{ provider }}>
</a>
</div>
</li>
+10 -1
View File
@@ -8,6 +8,7 @@ use App\Entity\Project\ProjectDeadline;
use App\Entity\Project\ProjectStatus;
use App\Entity\User\User;
use App\Factory\Project\ProjectFactory;
use App\Service\Project\ProjectService;
use Doctrine\ORM\EntityManagerInterface;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
@@ -18,12 +19,14 @@ class ProjectCalendarTest extends ApiTestCase
use Factories;
private EntityManagerInterface $entityManager;
private ProjectService $projectService;
private User $owner;
public function setUp(): void
{
self::bootKernel();
$this->entityManager = static::getContainer()->get(EntityManagerInterface::class);
$this->projectService = static::getContainer()->get(ProjectService::class);
$this->owner = $this->createTestUser();
$this->entityManager->persist($this->owner);
@@ -44,6 +47,7 @@ class ProjectCalendarTest extends ApiTestCase
{
return ProjectFactory::createOne([
'owner' => $this->owner,
'status' => ProjectStatus::ToCampaign,
'deadline' => $deadline,
])->_real();
}
@@ -56,7 +60,9 @@ class ProjectCalendarTest extends ApiTestCase
$this->entityManager->persist($project);
$this->entityManager->flush();
$project->setStatus(ProjectStatus::InCampaign);
$project = $this->projectService->transition($project, ProjectStatus::InCampaign);
$this->entityManager->persist($project);
$this->entityManager->flush();
return $project;
@@ -65,8 +71,11 @@ class ProjectCalendarTest extends ApiTestCase
public function testReleaseDateUpdatesOnCampaignStart(): void
{
$project = $this->createProjectAndSetToInCampaign();
$today = new \DateTime();
$today->setTime(0, 0, 0, 0);
$this->assertNotNull($project->getCalendar()->release);
$this->assertGreaterThan($today, $project->getCalendar()->release);
}
public function testMinimumDeadlineIsSetCorrectly(): void
@@ -0,0 +1,81 @@
<?php
namespace App\Tests\Entity;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectDeadline;
use App\Entity\Project\ProjectStatus;
use App\Entity\Project\Review;
use App\Entity\Project\ReviewType;
use App\Entity\User\User;
use App\Factory\Project\ProjectFactory;
use App\Service\Project\ProjectService;
use Doctrine\ORM\EntityManagerInterface;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
class ProjectCampaignReviewTest extends ApiTestCase
{
use ResetDatabase;
use Factories;
private EntityManagerInterface $entityManager;
private ProjectService $projectService;
private User $owner;
public function setUp(): void
{
self::bootKernel();
$this->entityManager = static::getContainer()->get(EntityManagerInterface::class);
$this->projectService = static::getContainer()->get(ProjectService::class);
$this->owner = $this->createTestUser();
$this->entityManager->persist($this->owner);
$this->entityManager->flush();
}
private function createTestUser(string $handle = 'test_user', string $email = 'testuser@example.com'): User
{
$user = new User();
$user->setHandle($handle);
$user->setEmail($email);
$user->setPassword('projectapitestpassword');
return $user;
}
private function createTestProject(ProjectDeadline $deadline = ProjectDeadline::Minimum): Project
{
return ProjectFactory::createOne([
'owner' => $this->owner,
'deadline' => $deadline,
'status' => ProjectStatus::InDraft,
])->_real();
}
private function createProjectAndSetToInCampaignReview(): Project
{
$project = $this->createTestProject();
$this->entityManager->persist($project);
$this->entityManager->flush();
$project = $this->projectService->transition($project, ProjectStatus::ToCampaignReview);
$this->entityManager->persist($project);
$this->entityManager->flush();
return $project;
}
public function testCreatesReviewOnStatusChange(): void
{
$project = $this->createProjectAndSetToInCampaignReview();
$reviews = $this->entityManager->getRepository(Review::class)->findBy(['project' => $project->getId()]);
$this->assertNotEmpty($reviews);
$this->assertEquals(1, \count($reviews));
$this->assertEquals(ReviewType::Campaign->value, $reviews[0]->getType()->value);
}
}