Merge branch 'main' into feat/dtf-api
This commit is contained in:
@@ -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 ###
|
||||
|
||||
@@ -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)%'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)',
|
||||
)]
|
||||
|
||||
@@ -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)',
|
||||
)]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -11,6 +11,8 @@ use Doctrine\Persistence\ManagerRegistry;
|
||||
*/
|
||||
class CategoryRepository extends ServiceEntityRepository
|
||||
{
|
||||
use FindByIdOrSlugTrait;
|
||||
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Category::class);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
// ;
|
||||
// }
|
||||
}
|
||||
@@ -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()
|
||||
// ;
|
||||
// }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user