Merge pull request #77 from goteo/feat/project-deadlines
[feat] Project deadlines
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user