Merge pull request #77 from goteo/feat/project-deadlines

[feat] Project deadlines
This commit is contained in:
2025-03-06 10:52:57 +01:00
committed by GitHub
11 changed files with 367 additions and 20 deletions
+29 -14
View File
@@ -8,16 +8,18 @@ use ApiPlatform\Metadata as API;
use App\ApiResource\Accounting\AccountingApiResource;
use App\ApiResource\LocalizedApiResourceTrait;
use App\ApiResource\User\UserApiResource;
use App\Dto\ProjectCreateDto;
use App\Dto\ProjectUpdateDto;
use App\Entity\Project\Category;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectCalendar;
use App\Entity\Project\ProjectDeadline;
use App\Entity\Project\ProjectStatus;
use App\Entity\Project\ProjectVideo;
use App\Mapping\Transformer\BudgetMapTransformer;
use App\Mapping\Transformer\ProjectVideoMapTransformer;
use App\State\ApiResourceStateProvider;
use App\State\Project\ProjectStateProcessor;
use AutoMapper\Attribute\MapFrom;
use AutoMapper\Attribute\MapTo;
use Symfony\Component\Validator\Constraints as Assert;
/**
@@ -27,12 +29,19 @@ use Symfony\Component\Validator\Constraints as Assert;
shortName: 'Project',
stateOptions: new Options(entityClass: Project::class),
provider: ApiResourceStateProvider::class,
processor: ProjectStateProcessor::class
)]
#[API\GetCollection()]
#[API\Post(security: 'is_granted("ROLE_USER")')]
#[API\Post(
input: ProjectCreateDto::class,
processor: ProjectStateProcessor::class,
security: 'is_granted("ROLE_USER")',
)]
#[API\Get()]
#[API\Patch(security: 'is_granted("PROJECT_EDIT", previous_object)')]
#[API\Patch(
input: ProjectUpdateDto::class,
processor: ProjectStateProcessor::class,
security: 'is_granted("PROJECT_EDIT", previous_object)',
)]
#[API\Delete(security: 'is_granted("PROJECT_EDIT", previous_object)')]
class ProjectApiResource
{
@@ -67,6 +76,20 @@ class ProjectApiResource
#[Assert\NotBlank()]
public string $subtitle;
/**
* On `minimum`, Project will campaign until the minimum deadline.\
* On `optimum`, Project will campaing until the minimum deadline,
* and then until the optimum deadline if it did raise the minimum.
*/
#[API\ApiProperty(writable: false)]
public ProjectDeadline $deadline;
/**
* Deadlines and important Project dates.
*/
#[API\ApiProperty(writable: false)]
public ProjectCalendar $calendar;
/**
* One of the available categories.
*/
@@ -88,20 +111,12 @@ class ProjectApiResource
#[Assert\NotBlank()]
public string $description;
/**
* A URL to a video showcasing the Project.
*/
#[Assert\Url()]
#[API\ApiProperty(readable: false)]
#[MapTo(target: Project::class, transformer: ProjectVideoMapTransformer::class)]
public string $video;
/**
* Extracted embedding data from the Project's video.
*/
#[API\ApiProperty(writable: false)]
#[MapFrom(source: Project::class, property: 'video')]
public ProjectVideo $videoEmbed;
public ProjectVideo $video;
/**
* The status of a Project represents how far it is in it's life-cycle.
+43
View File
@@ -4,6 +4,7 @@ namespace App\Benzina;
use App\Entity\Project\Category;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectDeadline;
use App\Entity\Project\ProjectStatus;
use App\Entity\Project\ProjectTerritory;
use App\Entity\Project\ProjectVideo;
@@ -11,6 +12,7 @@ use App\Entity\Project\Update;
use App\Entity\User\User;
use App\Repository\User\UserRepository;
use App\Service\Embed\EmbedService;
use App\Service\Project\CalendarService;
use App\Service\Project\TerritoryService;
use Goteo\Benzina\Pump\ArrayPumpTrait;
use Goteo\Benzina\Pump\DoctrinePumpTrait;
@@ -27,6 +29,7 @@ class ProjectsPump implements PumpInterface
private UserRepository $userRepository,
private TerritoryService $territoryService,
private EmbedService $embedService,
private CalendarService $calendarService,
) {}
public function supports(mixed $sample): bool
@@ -74,6 +77,16 @@ class ProjectsPump implements PumpInterface
$project->addUpdate($update);
}
$conf = $this->getProjectConf($project, $context);
$project->setDeadline($this->getProjectDeadline($conf));
$project->setCalendar($this->calendarService->makeCalendar(
$project->getDeadline(),
new \DateTimeImmutable($record['published']),
$conf['days_round1'],
$conf['days_round2'],
));
$this->persist($project, $context);
}
@@ -235,4 +248,34 @@ class ProjectsPump implements PumpInterface
return $query->fetchAll();
}
private function getProjectConf(Project $project, array $context): array
{
$query = $this->getDbConnection($context)->prepare(
'SELECT * FROM `project_conf` WHERE `project` = :project'
);
$query->execute(['project' => $project->getMigratedId()]);
$conf = $query->fetch();
if (!\is_array($conf)) {
return [
'one_round' => 0,
'days_round1' => 40,
'days_round2' => 40,
];
}
return $conf;
}
private function getProjectDeadline(array $conf): ProjectDeadline
{
if ($conf['one_round'] < 1) {
return ProjectDeadline::Optimum;
}
return ProjectDeadline::Minimum;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Dto;
use App\ApiResource\Project\ProjectTerritoryApiResource;
use App\Entity\Project\Category;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectDeadline;
use App\Mapping\Transformer\ProjectVideoMapTransformer;
use AutoMapper\Attribute\MapTo;
use Symfony\Component\Validator\Constraints as Assert;
class ProjectCreateDto
{
/**
* Main headline for the Project.
*/
#[Assert\NotBlank()]
public string $title;
/**
* Secondary headline for the Project.
*/
#[Assert\NotBlank()]
public string $subtitle;
/**
* One of the available categories.
*/
#[Assert\NotBlank()]
public Category $category;
/**
* ISO 3166 data about the Project's territory of interest.
*/
#[Assert\NotBlank()]
#[Assert\Valid()]
public ProjectTerritoryApiResource $territory;
/**
* Free-form rich text description for the Project.
*/
#[Assert\NotBlank()]
public string $description;
/**
* On `minimum`, Project will campaign until the minimum deadline.\
* On `optimum`, Project will campaing until the minimum deadline,
* and then until the optimum deadline if it did raise the minimum.
*/
public ProjectDeadline $deadline = ProjectDeadline::Minimum;
/**
* A URL to a video showcasing the Project.
*/
#[Assert\Url()]
#[MapTo(target: Project::class, transformer: ProjectVideoMapTransformer::class)]
public string $video;
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Dto;
use ApiPlatform\Metadata as API;
use App\ApiResource\Project\ProjectTerritoryApiResource;
use App\Entity\Project\Category;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectDeadline;
use App\Entity\Project\ProjectStatus;
use App\Mapping\Transformer\ProjectVideoMapTransformer;
use AutoMapper\Attribute\MapTo;
use Symfony\Component\Validator\Constraints as Assert;
class ProjectUpdateDto
{
#[API\ApiProperty(identifier: true, writable: false)]
public int $id;
/**
* Main headline for the Project.
*/
public string $title;
/**
* Secondary headline for the Project.
*/
public string $subtitle;
/**
* One of the available categories.
*/
public Category $category;
/**
* ISO 3166 data about the Project's territory of interest.
*/
#[Assert\Valid()]
public ProjectTerritoryApiResource $territory;
/**
* Free-form rich text description for the Project.
*/
public string $description;
/**
* On `minimum`, Project will campaign until the minimum deadline.\
* On `optimum`, Project will campaing until the minimum deadline,
* and then until the optimum deadline if it did raise the minimum.
*/
public ProjectDeadline $deadline;
/**
* A URL to a video showcasing the Project.
*/
#[Assert\Url()]
#[MapTo(target: Project::class, transformer: ProjectVideoMapTransformer::class)]
public string $video;
/**
* The status of a Project represents how far it is in it's life-cycle.
*/
public ProjectStatus $status;
}
+31 -1
View File
@@ -50,6 +50,12 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
#[Gedmo\Translatable()]
private ?string $subtitle = null;
#[ORM\Column(enumType: ProjectDeadline::class)]
private ?ProjectDeadline $deadline = null;
#[ORM\Embedded(class: ProjectCalendar::class)]
private ?ProjectCalendar $calendar = null;
#[ORM\Column(enumType: Category::class)]
private ?Category $category = null;
@@ -90,7 +96,7 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
* Projects have a start and an end, and in the meantime they go through different phases represented under this status.
*/
#[ORM\Column(type: 'string', enumType: ProjectStatus::class)]
private ProjectStatus $status;
private ProjectStatus $status = ProjectStatus::InEditing;
/**
* @var Collection<int, Reward>
@@ -154,6 +160,30 @@ class Project implements UserOwnedInterface, AccountingOwnerInterface, Localized
return $this;
}
public function getDeadline(): ?ProjectDeadline
{
return $this->deadline;
}
public function setDeadline(ProjectDeadline $deadline): static
{
$this->deadline = $deadline;
return $this;
}
public function getCalendar(): ?ProjectCalendar
{
return $this->calendar;
}
public function setCalendar(ProjectCalendar $calendar): static
{
$this->calendar = $calendar;
return $this;
}
public function getCategory(): ?Category
{
return $this->category;
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Entity\Project;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Embeddable()]
class ProjectCalendar
{
/**
* The date at which the Project started campaigning.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
public ?\DateTimeImmutable $release = null;
/**
* 40 days after the date of release.\
* \
* The minimum budget must be raised by the end of this date.\
* Failure to do so will move the Project out of status `in_campaign` into status `unfunded`.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
public ?\DateTimeImmutable $minimum = null;
/**
* 40 days after the minimum deadline. Optional.\
* \
* If the Project achieved their minimum budget by the minimum deadline,
* and this deadline is defined, it may still remain in campaign to raise the optimum.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
public ?\DateTimeImmutable $optimum = null;
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Entity\Project;
enum ProjectDeadline: string
{
/**
* A single deadline means the Project will only remain in campaing
* for the minimum budget deadline.
*/
case Minimum = 'minimum';
/**
* A double deadline means the Project can remain in campaing
* past the minimum budget deadline until the optimum budget deadline,
* if it did raise the minimum in the first deadline.
*/
case Optimum = 'optimum';
}
+37
View File
@@ -0,0 +1,37 @@
<?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);
}
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Service\Project;
use App\Entity\Project\ProjectCalendar;
use App\Entity\Project\ProjectDeadline;
class CalendarService
{
public function makeCalendar(
ProjectDeadline $deadline,
\DateTimeImmutable $release = new \DateTimeImmutable(),
int $minimumTtl = 40,
int $optimumTtl = 40,
): ProjectCalendar {
$minimumTtl = \DateInterval::createFromDateString(\sprintf('%d days', $minimumTtl));
$optimumTtl = \DateInterval::createFromDateString(\sprintf('%d days', $optimumTtl));
$calendar = new ProjectCalendar();
$calendar->release = $release;
switch ($deadline) {
case ProjectDeadline::Minimum:
$calendar->minimum = $calendar->release->add($minimumTtl);
break;
case ProjectDeadline::Optimum:
$calendar->minimum = $calendar->release->add($minimumTtl);
$calendar->optimum = $calendar->minimum->add($optimumTtl);
break;
}
return $calendar;
}
}
+15 -5
View File
@@ -5,6 +5,8 @@ namespace App\State\Project;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\Project\ProjectApiResource;
use App\Dto\ProjectCreateDto;
use App\Dto\ProjectUpdateDto;
use App\Entity\Project\Project;
use App\Mapping\AutoMapper;
use App\Service\Auth\AuthService;
@@ -20,16 +22,17 @@ class ProjectStateProcessor implements ProcessorInterface
) {}
/**
* @param ProjectApiResource $data
* @param ProjectCreateDto|ProjectUpdateDto $data
* @param array{id: int} $uriVariables
*
* @return ProjectApiResource|null
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
/** @var Project */
$project = $this->autoMapper->map($data, Project::class);
if ($data instanceof ProjectCreateDto) {
/** @var Project */
$project = $this->autoMapper->map($data, Project::class);
if (!$project->getId()) {
$owner = $this->authService->getUser();
if (!$owner) {
@@ -39,12 +42,19 @@ class ProjectStateProcessor implements ProcessorInterface
$project->setOwner($owner);
}
if ($data instanceof ProjectUpdateDto) {
$data = $this->autoMapper->map($uriVariables, $data);
/** @var Project */
$project = $this->autoMapper->map($data, Project::class);
}
$project = $this->entityStateProcessor->process($project, $operation, $uriVariables, $context);
if ($project === null) {
return null;
}
return $this->autoMapper->map($project, $data);
return $this->autoMapper->map($project, ProjectApiResource::class);
}
}
+2
View File
@@ -5,6 +5,7 @@ namespace App\Tests\Entity;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Project\Category;
use App\Entity\Project\Project;
use App\Entity\Project\ProjectDeadline;
use App\Entity\Project\ProjectStatus;
use App\Entity\Project\ProjectTerritory;
use App\Entity\User\User;
@@ -43,6 +44,7 @@ class ProjectApiTest extends ApiTestCase
$project = new Project();
$project->setTitle('Test Project');
$project->setSubtitle('Test Project Subtitle');
$project->setDeadline(ProjectDeadline::Minimum);
$project->setCategory(Category::LibreSoftware);
$project->setDescription('Test Project Description');
$project->setTerritory(new ProjectTerritory('ES'));