diff --git a/Controller/SingleTaskController.php b/Controller/SingleTaskController.php index d5a460432..4c813a404 100644 --- a/Controller/SingleTaskController.php +++ b/Controller/SingleTaskController.php @@ -20,6 +20,8 @@ use Chill\MainBundle\Entity\User; use Chill\PersonBundle\Security\Authorization\PersonVoter; use Chill\PersonBundle\Entity\PersonRepository; use Chill\MainBundle\Entity\UserRepository; +use Chill\TaskBundle\Event\TaskEvent; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; class SingleTaskController extends Controller { @@ -30,8 +32,10 @@ class SingleTaskController extends Controller * name="chill_task_single_task_new" * ) */ - public function newAction(Request $request) - { + public function newAction( + Request $request, + EventDispatcherInterface $dispatcher + ) { $task = (new SingleTask()) ->setAssignee($this->getUser()) @@ -67,6 +71,8 @@ class SingleTaskController extends Controller if ($form->isValid()) { $em = $this->getDoctrine()->getManager(); $em->persist($task); + + $dispatcher->dispatch(TaskEvent::PERSIST, new TaskEvent($task)); $em->flush(); diff --git a/DependencyInjection/ChillTaskExtension.php b/DependencyInjection/ChillTaskExtension.php index acc4257a1..6cf8550e3 100644 --- a/DependencyInjection/ChillTaskExtension.php +++ b/DependencyInjection/ChillTaskExtension.php @@ -32,6 +32,8 @@ class ChillTaskExtension extends Extension implements PrependExtensionInterface $loader->load('services/workflow.yml'); $loader->load('services/templating.yml'); $loader->load('services/menu.yml'); + $loader->load('services/event.yml'); + $loader->load('services/timeline.yml'); } public function prepend(ContainerBuilder $container) diff --git a/DependencyInjection/Compiler/TaskWorkflowDefinitionCompilerPass.php b/DependencyInjection/Compiler/TaskWorkflowDefinitionCompilerPass.php index 81d387922..76e849d73 100644 --- a/DependencyInjection/Compiler/TaskWorkflowDefinitionCompilerPass.php +++ b/DependencyInjection/Compiler/TaskWorkflowDefinitionCompilerPass.php @@ -22,6 +22,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Chill\TaskBundle\Workflow\TaskWorkflowManager; use Symfony\Component\DependencyInjection\Reference; use Chill\TaskBundle\Templating\UI\CountNotificationTask; +use Chill\TaskBundle\Event\Lifecycle\TaskLifecycleEvent; /** * @@ -39,6 +40,7 @@ class TaskWorkflowDefinitionCompilerPass implements CompilerPassInterface $workflowManagerDefinition = $container->getDefinition(TaskWorkflowManager::class); $counterDefinition = $container->getDefinition(CountNotificationTask::class); + $lifecycleDefinition = $container->getDefinition(TaskLifecycleEvent::class); foreach ($container->findTaggedServiceIds('chill_task.workflow_definition') as $id => $tags) { // registering the definition to manager @@ -58,6 +60,12 @@ class TaskWorkflowDefinitionCompilerPass implements CompilerPassInterface 'method' => 'resetCacheOnNewStates', 'priority' => 0 ]); + $lifecycleDefinition + ->addTag('kernel.event_listener', [ + 'event' => sprintf('workflow.%s.transition', $definition->getClass()::getAssociatedWorkflowName()), + 'method' => 'onTransition', + 'priority' => 0 + ]); } } } diff --git a/Entity/AbstractTask.php b/Entity/AbstractTask.php index 401891e12..1f71d935e 100644 --- a/Entity/AbstractTask.php +++ b/Entity/AbstractTask.php @@ -86,6 +86,11 @@ abstract class AbstractTask implements HasScopeInterface, HasCenterInterface * @ORM\Column(name="closed", type="boolean", options={ "default"=false }) */ private $closed = false; + + public function __construct() + { + + } /** * Set type diff --git a/Entity/SingleTask.php b/Entity/SingleTask.php index c56c584ef..45dcda54c 100644 --- a/Entity/SingleTask.php +++ b/Entity/SingleTask.php @@ -4,6 +4,7 @@ namespace Chill\TaskBundle\Entity; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; +use Doctrine\Common\Collections\Collection; /** * SingleTask @@ -58,8 +59,25 @@ class SingleTask extends AbstractTask * ) */ private $recurringTask; + + /** + * + * @var \Doctrine\Common\Collections\Collection + * @ORM\OneToMany( + * targetEntity="\Chill\TaskBundle\Entity\Task\SingleTaskPlaceEvent", + * mappedBy="task" + * ) + */ + private $taskPlaceEvents; + + public function __construct() + { + $this->taskPlaceEvents = $events = new \Doctrine\Common\Collections\ArrayCollection; + + parent::__construct(); + } - + /** * Get id * @@ -174,6 +192,16 @@ class SingleTask extends AbstractTask $this->recurringTask = $recurringTask; } + public function getTaskPlaceEvents(): Collection + { + return $this->taskPlaceEvents; + } + public function setTaskPlaceEvents(Collection $taskPlaceEvents) + { + $this->taskPlaceEvents = $taskPlaceEvents; + + return $this; + } } diff --git a/Entity/Task/AbstractTaskPlaceEvent.php b/Entity/Task/AbstractTaskPlaceEvent.php new file mode 100644 index 000000000..91ce89a15 --- /dev/null +++ b/Entity/Task/AbstractTaskPlaceEvent.php @@ -0,0 +1,154 @@ +datetime = new \DateTimeImmutable('now'); + } + + /** + * Get id. + * + * @return int + */ + public function getId() + { + return $this->id; + } + + /** + * Set datetime. + * + * @param datetime_immutable $datetime + * + * @return AbstractTaskPlaceEvent + */ + public function setDatetime($datetime) + { + $this->datetime = $datetime; + + return $this; + } + + /** + * Get datetime. + * + * @return datetime_immutable + */ + public function getDatetime() + { + return $this->datetime; + } + + /** + * Set transition. + * + * @param string $transition + * + * @return AbstractTaskPlaceEvent + */ + public function setTransition($transition) + { + $this->transition = $transition; + + return $this; + } + + /** + * Get transition. + * + * @return string + */ + public function getTransition() + { + return $this->transition; + } + + /** + * Set data. + * + * @param string $data + * + * @return AbstractTaskPlaceEvent + */ + public function setData($data) + { + $this->data = $data; + + return $this; + } + + /** + * Get data. + * + * @return string + */ + public function getData() + { + return $this->data; + } + + public function getAuthor(): User + { + return $this->author; + } + + public function setAuthor(User $author) + { + $this->author = $author; + + return $this; + } + + +} diff --git a/Entity/Task/SingleTaskPlaceEvent.php b/Entity/Task/SingleTaskPlaceEvent.php new file mode 100644 index 000000000..c2facea43 --- /dev/null +++ b/Entity/Task/SingleTaskPlaceEvent.php @@ -0,0 +1,56 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Chill\TaskBundle\Entity\Task; + +use Doctrine\ORM\Mapping as ORM; +use Chill\TaskBundle\Entity\SingleTask; + +/** + * + * + * @ORM\Table("chill_task.single_task_place_event") + * @ORM\Entity() + * + * @author Julien Fastré + */ +class SingleTaskPlaceEvent extends AbstractTaskPlaceEvent +{ + /** + * + * @var SingleTask + * @ORM\ManyToOne( + * targetEntity="\Chill\TaskBundle\Entity\SingleTask", + * inversedBy="taskPlaceEvents" + * ) + */ + protected $task; + + public function getTask(): SingleTask + { + return $this->task; + } + + public function setTask(SingleTask $task) + { + $this->task = $task; + + return $this; + } + + +} diff --git a/Event/Lifecycle/TaskLifecycleEvent.php b/Event/Lifecycle/TaskLifecycleEvent.php new file mode 100644 index 000000000..74bd33cc9 --- /dev/null +++ b/Event/Lifecycle/TaskLifecycleEvent.php @@ -0,0 +1,104 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Chill\TaskBundle\Event\Lifecycle; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Chill\TaskBundle\Event\TaskEvent; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Chill\TaskBundle\Entity\Task\SingleTaskPlaceEvent; +use Symfony\Component\Workflow\Event\Event as WorkflowEvent; + +/** + * + * + * @author Julien Fastré + */ +class TaskLifecycleEvent implements EventSubscriberInterface +{ + /** + * + * @var TokenStorageInterface + */ + protected $tokenStorage; + + /** + * + * @var EntityManagerInterface + */ + protected $em; + + public function __construct( + TokenStorageInterface $tokenStorage, + EntityManagerInterface $em + ) { + $this->tokenStorage = $tokenStorage; + $this->em = $em; + } + + + public static function getSubscribedEvents(): array + { + return [ + TaskEvent::PERSIST => [ + 'onTaskPersist' + ] + ]; + } + + public function onTaskPersist(TaskEvent $e) + { + $task = $e->getTask(); + $user = $this->tokenStorage->getToken()->getUser(); + + $event = (new SingleTaskPlaceEvent()) + ->setTask($task) + ->setAuthor($user) + ->setTransition('_creation') + ->setData([ + 'new_states' => $task->getCurrentStates() + ]) + ; + + $task->getTaskPlaceEvents()->add($event); + + $this->em->persist($event); + } + + public function onTransition(WorkflowEvent $e) + { + $task = $e->getSubject(); + $user = $this->tokenStorage->getToken()->getUser(); + + $event = (new SingleTaskPlaceEvent()) + ->setTask($task) + ->setAuthor($user) + ->setTransition($e->getTransition()->getName()) + ->setData([ + 'old_states' => $e->getTransition()->getFroms(), + 'new_states' => $e->getTransition()->getTos(), + 'workflow' => $e->getWorkflowName() + ]) + ; + + $task->getTaskPlaceEvents()->add($event); + + $this->em->persist($event); + } + +} diff --git a/Event/TaskEvent.php b/Event/TaskEvent.php new file mode 100644 index 000000000..070118c4d --- /dev/null +++ b/Event/TaskEvent.php @@ -0,0 +1,55 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Chill\TaskBundle\Event; + +use Chill\TaskBundle\Entity\AbstractTask; +use Symfony\Component\EventDispatcher\Event; + +/** + * + * + * @author Julien Fastré + */ +class TaskEvent extends Event +{ + const PERSIST = 'chill_task.task_persist'; + + /** + * + * @var AbstractTask + */ + protected $task; + + public function __construct(AbstractTask $task) + { + $this->task = $task; + } + + public function getTask(): AbstractTask + { + return $this->task; + } + + public function setTask(AbstractTask $task) + { + $this->task = $task; + + return $this; + } +} + diff --git a/Resources/config/services/event.yml b/Resources/config/services/event.yml new file mode 100644 index 000000000..385d062cc --- /dev/null +++ b/Resources/config/services/event.yml @@ -0,0 +1,7 @@ +services: + Chill\TaskBundle\Event\Lifecycle\TaskLifecycleEvent: + arguments: + $em: '@Doctrine\ORM\EntityManagerInterface' + $tokenStorage: '@Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface' + tags: + - { name: kernel.event_subscriber } diff --git a/Resources/config/services/timeline.yml b/Resources/config/services/timeline.yml new file mode 100644 index 000000000..50187f14b --- /dev/null +++ b/Resources/config/services/timeline.yml @@ -0,0 +1,8 @@ +services: + Chill\TaskBundle\Timeline\TaskLifeCycleEventTimelineProvider: + arguments: + $em: '@Doctrine\ORM\EntityManagerInterface' + $registry: '@Symfony\Component\Workflow\Registry' + tags: + - { name: 'chill.timeline', context: 'person' } + \ No newline at end of file diff --git a/Resources/migrations/Version20180502194119.php b/Resources/migrations/Version20180502194119.php new file mode 100644 index 000000000..a07ad1089 --- /dev/null +++ b/Resources/migrations/Version20180502194119.php @@ -0,0 +1,34 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.'); + + $this->addSql('CREATE SEQUENCE chill_task.single_task_place_event_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE chill_task.single_task_place_event (id INT NOT NULL, author_id INT DEFAULT NULL, task_id INT DEFAULT NULL, datetime TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, transition VARCHAR(255) NOT NULL, data JSONB NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_D459EBEEF675F31B ON chill_task.single_task_place_event (author_id)'); + $this->addSql('CREATE INDEX IDX_D459EBEE8DB60186 ON chill_task.single_task_place_event (task_id)'); + $this->addSql('COMMENT ON COLUMN chill_task.single_task_place_event.datetime IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_task.single_task_place_event ADD CONSTRAINT FK_D459EBEEF675F31B FOREIGN KEY (author_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_task.single_task_place_event ADD CONSTRAINT FK_D459EBEE8DB60186 FOREIGN KEY (task_id) REFERENCES chill_task.single_task (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema) + { + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.'); + + $this->addSql('DROP SEQUENCE chill_task.single_task_place_event_id_seq CASCADE'); + $this->addSql('DROP TABLE chill_task.single_task_place_event'); + + } +} diff --git a/Resources/translations/messages.fr.yml b/Resources/translations/messages.fr.yml index f9d54eb57..1797d370e 100644 --- a/Resources/translations/messages.fr.yml +++ b/Resources/translations/messages.fr.yml @@ -59,6 +59,10 @@ Year: Année(s) start: démarrer close: clotûrer cancel: annuler +'%user% has closed the task': %user% a fermé la tâche +'%user% has canceled the task': %user% a annulé la tâche +'%user% has started the task': %user% a débuté la tâche +'%user% has created the task': %user% a introduit la tâche #Flash messages 'The task is created': 'La tâche a été créée' diff --git a/Resources/views/SingleTask/_list.html.twig b/Resources/views/SingleTask/_list.html.twig index f3f5dfb3f..12fe19314 100644 --- a/Resources/views/SingleTask/_list.html.twig +++ b/Resources/views/SingleTask/_list.html.twig @@ -57,7 +57,7 @@   diff --git a/Resources/views/Timeline/single_task_transition_person_context.html.twig b/Resources/views/Timeline/single_task_transition_person_context.html.twig new file mode 100644 index 000000000..681765599 --- /dev/null +++ b/Resources/views/Timeline/single_task_transition_person_context.html.twig @@ -0,0 +1,33 @@ +
+

+ {{ event.datetime|localizeddate('long', 'none') }} + / {{ 'Task'|trans }} / + {% if transition is not null %} + {{ task_workflow_metadata(event.task, 'transition.sentence', transition)|trans({ '%user%': event.author.username }) }} + {% else %} + {{ '%user% has created the task'|trans({ '%user%': event.author.username }) }} + {% endif %} +

+ +
+
+
{{ 'title'|trans }}
+
{{ event.task.title }}
+ + {% if event.task.description is not empty %} +
{{ 'Description'|trans }}
+
+
+ {{ event.task.description }} +
+
+ {% endif %} + + {% if event.task.endDate is not empty %} +
{{ 'Task end date'|trans }}
+
{{ event.task.endDate|localizeddate('medium', 'none') }}
+ {% endif %} +
+
+ +
diff --git a/Timeline/TaskLifeCycleEventTimelineProvider.php b/Timeline/TaskLifeCycleEventTimelineProvider.php new file mode 100644 index 000000000..577f4dfac --- /dev/null +++ b/Timeline/TaskLifeCycleEventTimelineProvider.php @@ -0,0 +1,131 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Chill\TaskBundle\Timeline; + +use Chill\MainBundle\Timeline\TimelineProviderInterface; +use Doctrine\ORM\EntityManagerInterface; +use Chill\TaskBundle\Entity\Task\SingleTaskPlaceEvent; +use Chill\TaskBundle\Entity\SingleTask; +use Symfony\Component\Workflow\Registry; +use Symfony\Component\Workflow\Workflow; + +/** + * + * + * @author Julien Fastré + */ +class TaskLifeCycleEventTimelineProvider implements TimelineProviderInterface +{ + /** + * + * @var EntityManagerInterface + */ + protected $em; + + /** + * + * @var Registry + */ + protected $registry; + + const TYPE = 'chill_task.transition'; + + public function __construct(EntityManagerInterface $em, Registry $registry) + { + $this->em = $em; + $this->registry = $registry; + } + + public function fetchQuery($context, $args) + { + if ($context !== 'person') { + throw new \LogicException(sprintf('%s is not able ' + . 'to render context %s', self::class, $context)); + } + + $metadata = $this->em + ->getClassMetadata(SingleTaskPlaceEvent::class); + $singleTaskMetadata = $this->em + ->getClassMetadata(SingleTask::class); + + return [ + 'id' => sprintf('%s.%s.%s', $metadata->getSchemaName(), $metadata->getTableName(), $metadata->getColumnName('id')), + 'type' => self::TYPE, + 'date' => $metadata->getColumnName('datetime'), + 'FROM' => sprintf('%s JOIN %s ON %s = %s', + sprintf('%s.%s', $metadata->getSchemaName(), $metadata->getTableName()), + sprintf('%s.%s', $singleTaskMetadata->getSchemaName(), $singleTaskMetadata->getTableName()), + $metadata->getAssociationMapping('task')['joinColumns'][0]['name'], + sprintf('%s.%s.%s', $singleTaskMetadata->getSchemaName(), $singleTaskMetadata->getTableName(), $singleTaskMetadata->getColumnName('id')) + ), + 'WHERE' => sprintf('%s.%s = %d', + sprintf('%s.%s', $singleTaskMetadata->getSchemaName(), $singleTaskMetadata->getTableName()), + $singleTaskMetadata->getAssociationMapping('person')['joinColumns'][0]['name'], + $args['person']->getId() + ) + ]; + } + + public function getEntities(array $ids) + { + $events = $this->em + ->getRepository(SingleTaskPlaceEvent::class) + ->findBy([ 'id' => $ids ]) + ; + + return \array_combine( + \array_map(function($e) { return $e->getId(); }, $events ), + $events + ); + } + + public function getEntityTemplate($entity, $context, array $args) + { + $workflow = $this->registry->get($entity->getTask(), $entity->getData['workflow']); + $transition = $this->getTransitionByName($entity->getTransition(), $workflow); + + return [ + 'template' => 'ChillTaskBundle:Timeline:single_task_transition_person_context.html.twig', + 'template_data' => [ + 'person' => $args['person'], + 'event' => $entity, + 'transition' => $transition + ] + ]; + } + + /** + * + * @param string $name + * @param Workflow $workflow + * @return \Symfony\Component\Workflow\Transition + */ + protected function getTransitionByName($name, Workflow $workflow) + { + foreach ($workflow->getDefinition()->getTransitions() as $transition) { + if ($transition->getName() === $name) { + return $transition; + } + } + } + + public function supportsType($type): bool + { + return $type === self::TYPE; + } +} diff --git a/Workflow/Definition/DefaultTaskDefinition.php b/Workflow/Definition/DefaultTaskDefinition.php index cbe9d8350..8c26edcb4 100644 --- a/Workflow/Definition/DefaultTaskDefinition.php +++ b/Workflow/Definition/DefaultTaskDefinition.php @@ -31,15 +31,18 @@ class DefaultTaskDefinition implements \Chill\TaskBundle\Workflow\TaskWorkflowDe const TRANSITION_METADATA = [ 'close' => [ 'verb' => 'close', - 'class' => 'sc-button bt-task-label bt-task-close' + 'class' => 'sc-button bt-task-label bt-task-close', + 'sentence' => '%user% has closed the task' ], 'cancel' => [ 'verb' => 'cancel', - 'class' => 'sc-button bt-task-label bt-task-cancel' + 'class' => 'sc-button bt-task-label bt-task-cancel', + 'sentence' => '%user% has canceled the task' ], 'start' => [ 'verb' => 'start', - 'class' => 'sc-button bt-task-label bt-task-start' + 'class' => 'sc-button bt-task-label bt-task-start', + 'sentence' => '%user% has started the task' ] ];