Skip to content

Commit

Permalink
fix: Track changes to the tickets' ongoing contracts
Browse files Browse the repository at this point in the history
Doctrine doesn't dispatch the `postUpdate` event on ManyToMany relations
changes. It means that we cannot track these changes in the
EntityActivitySubscriber.

It seems to be doable with the `onFlush` event, but I wasn't succeeded
without spending too much time on this subject.

Reference: https://stackoverflow.com/q/31616315

Instead, I preferred to log the change manually, even though it's a bit of a
hack. I hope to find a better solution in the future, but it's good
enough for now.
  • Loading branch information
marien-probesys committed Nov 28, 2023
1 parent 417a76d commit c4da224
Show file tree
Hide file tree
Showing 7 changed files with 142 additions and 8 deletions.
21 changes: 21 additions & 0 deletions src/Controller/Tickets/ContractsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
namespace App\Controller\Tickets;

use App\Controller\BaseController;
use App\Entity\EntityEvent;
use App\Entity\Ticket;
use App\Repository\ContractRepository;
use App\Repository\EntityEventRepository;
use App\Repository\TicketRepository;
use App\Repository\TimeSpentRepository;
use App\Service\Sorter\ContractSorter;
Expand Down Expand Up @@ -53,6 +55,7 @@ public function update(
Ticket $ticket,
Request $request,
ContractRepository $contractRepository,
EntityEventRepository $entityEventRepository,
TicketRepository $ticketRepository,
TimeSpentRepository $timeSpentRepository,
ContractBilling $contractBilling,
Expand Down Expand Up @@ -95,16 +98,34 @@ public function update(
}
}

$changes = [];

if ($initialOngoingContract) {
$ticket->removeContract($initialOngoingContract);
$changes[] = $initialOngoingContract->getId();
} else {
$changes[] = null;
}

if ($newOngoingContract) {
$ticket->addContract($newOngoingContract);
$changes[] = $newOngoingContract->getId();
} else {
$changes[] = null;
}

$ticketRepository->save($ticket, true);

// Log changes to the ongoingContract field manually, as we cannot log
// these automatically with the EntityActivitySubscriber (i.e. ManyToMany
// relationships cannot be handled easily).
if ($changes[0] !== $changes[1]) {
$entityEvent = EntityEvent::initUpdate($ticket, [
'ongoingContract' => $changes,
]);
$entityEventRepository->save($entityEvent, true);
}

if ($chargeTimeSpent && $newOngoingContract) {
$timeSpents = $ticket->getTimeSpentsNotCharged()->getValues();
$contractBilling->chargeTimeSpents($newOngoingContract, $timeSpents);
Expand Down
8 changes: 8 additions & 0 deletions src/Entity/EntityEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ public function setChanges(array $changes): self
return $this;
}

/**
* Return true if the EntityEvent references the specified field.
*/
public function refersTo(string $field): bool
{
return isset($this->changes[$field]);
}

public static function initInsert(ActivityRecordableInterface $entity): self
{
$entityEvent = new self();
Expand Down
8 changes: 8 additions & 0 deletions src/Service/TicketTimeline.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ public function build(Ticket $ticket): Timeline
'type' => 'update',
]);

if (!$this->security->isGranted('orga:see:tickets:contracts', $organization)) {
// Make sure to remove events referencing contracts if the user
// doesn't have the permission to see them.
$events = array_filter($events, function ($event) {
return !$event->refersTo('ongoingContract');
});
}

$timeline->addItems($events);
}

Expand Down
59 changes: 51 additions & 8 deletions src/Twig/TicketEventChangesFormatterExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,19 @@

use App\Entity\EntityEvent;
use App\Entity\User;
use App\Repository\ContractRepository;
use App\Repository\UserRepository;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;

class TicketEventChangesFormatterExtension extends AbstractExtension
{
private UserRepository $userRepository;

private TranslatorInterface $translator;

public function __construct(UserRepository $userRepository, TranslatorInterface $translator)
{
$this->userRepository = $userRepository;
$this->translator = $translator;
public function __construct(
private ContractRepository $contractRepository,
private UserRepository $userRepository,
private TranslatorInterface $translator,
) {
}

public function getFilters(): array
Expand Down Expand Up @@ -57,6 +55,8 @@ public function formatTicketChanges(EntityEvent $event, string $field): string
return $this->formatRequesterChanges($user, $fieldChanges);
} elseif ($field === 'solution') {
return $this->formatSolutionChanges($user, $fieldChanges);
} elseif ($field === 'ongoingContract') {
return $this->formatOngoingContractChanges($user, $fieldChanges);
} else {
return $this->formatChanges($user, $field, $fieldChanges);
}
Expand Down Expand Up @@ -233,6 +233,49 @@ private function formatSolutionChanges(User $user, array $changes): string
}
}

/**
* @param array<?int> $changes
*/
private function formatOngoingContractChanges(User $user, array $changes): string
{
$username = $this->escape($user->getDisplayName());

if ($changes[0] === null) {
$newContract = $this->contractRepository->find($changes[1]);
$newContractName = $this->escape($newContract->getName());
return $this->translator->trans(
'tickets.events.ongoing_contract.new',
[
'username' => $username,
'newValue' => $newContractName,
],
);
} elseif ($changes[1] === null) {
$oldContract = $this->contractRepository->find($changes[0]);
$oldContractName = $this->escape($oldContract->getName());
return $this->translator->trans(
'tickets.events.ongoing_contract.removed',
[
'username' => $username,
'oldValue' => $oldContractName,
],
);
} else {
$oldContract = $this->contractRepository->find($changes[0]);
$oldContractName = $this->escape($oldContract->getName());
$newContract = $this->contractRepository->find($changes[1]);
$newContractName = $this->escape($newContract->getName());
return $this->translator->trans(
'tickets.events.ongoing_contract.changed',
[
'username' => $username,
'oldValue' => $oldContractName,
'newValue' => $newContractName,
],
);
}
}

/**
* @param mixed[] $changes
*/
Expand Down
48 changes: 48 additions & 0 deletions tests/Controller/Tickets/ContractsControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

namespace App\Tests\Controller\Tickets;

use App\Entity\EntityEvent;
use App\Entity\Ticket;
use App\Repository\EntityEventRepository;
use App\Tests\AuthorizationHelper;
use App\Tests\Factory\ContractFactory;
use App\Tests\Factory\OrganizationFactory;
Expand Down Expand Up @@ -109,6 +112,51 @@ public function testPostUpdateSavesTicketAndRedirects(): void
$this->assertSame($newContract->getId(), $contracts[0]->getId());
}

public function testPostUpdateLogsAnEntityEvent(): void
{
$client = static::createClient();
$container = static::getContainer();
/** @var \Doctrine\Bundle\DoctrineBundle\Registry */
$registry = $container->get('doctrine');
$entityManager = $registry->getManager();
/** @var EntityEventRepository */
$entityEventRepository = $entityManager->getRepository(EntityEvent::class);
$user = UserFactory::createOne();
$client->loginUser($user->object());
$this->grantOrga($user->object(), ['orga:update:tickets:contracts']);
$organization = OrganizationFactory::createOne();
$oldContract = ContractFactory::createOne([
'organization' => $organization,
'startAt' => Utils\Time::ago(1, 'week'),
'endAt' => Utils\Time::fromNow(1, 'week'),
]);
$newContract = ContractFactory::createOne([
'organization' => $organization,
'startAt' => Utils\Time::ago(1, 'week'),
'endAt' => Utils\Time::fromNow(1, 'week'),
]);
$ticket = TicketFactory::createOne([
'organization' => $organization,
'createdBy' => $user,
'contracts' => [$oldContract],
]);

$client->request('POST', "/tickets/{$ticket->getUid()}/contracts/edit", [
'_csrf_token' => $this->generateCsrfToken($client, 'update ticket contracts'),
'ongoingContractUid' => $newContract->getUid(),
]);

$entityEvent = $entityEventRepository->findOneBy([
'type' => 'update',
'entityType' => Ticket::class,
'entityId' => $ticket->getId(),
]);
$this->assertNotNull($entityEvent);
$changes = $entityEvent->getChanges();
$this->assertSame($oldContract->getId(), $changes['ongoingContract'][0]);
$this->assertSame($newContract->getId(), $changes['ongoingContract'][1]);
}

public function testPostUpdateCanChargeNotChargedTimeSpent(): void
{
$client = static::createClient();
Expand Down
3 changes: 3 additions & 0 deletions translations/messages+intl-icu.en_GB.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ tickets.events.default.changed: '<strong>{username}</strong> changed the {field}
tickets.events.default.new: '<strong>{username}</strong> changed the {field} to <i>{newValue}</i>'
tickets.events.default.removed: '<strong>{username}</strong> removed the {field} from <i>{oldValue}</i>'
tickets.events.impact: '<strong>{username}</strong> changed the impact from <i>{oldValue}</i> to <i>{newValue}</i>'
tickets.events.ongoing_contract.changed: '<strong>{username}</strong> changed the ongoing contract from <i>{oldValue}</i> to <i>{newValue}</i>'
tickets.events.ongoing_contract.new: '<strong>{username}</strong> changed the ongoing contract to <i>{newValue}</i>'
tickets.events.ongoing_contract.removed: '<strong>{username}</strong> withdrew the ongoing contract <i>{oldValue}</i>'
tickets.events.priority: '<strong>{username}</strong> changed the priority from <i>{oldValue}</i> to <i>{newValue}</i>'
tickets.events.requester: '<strong>{username}</strong> changed the requester from <i>{oldValue}</i> to <i>{newValue}</i>'
tickets.events.solution.changed: '<strong>{username}</strong> changed the solution'
Expand Down
3 changes: 3 additions & 0 deletions translations/messages+intl-icu.fr_FR.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ tickets.events.default.changed: '<strong>{username}</strong> a changé {field} d
tickets.events.default.new: '<strong>{username}</strong> a changé {field} à <i>{newValue}</i>'
tickets.events.default.removed: '<strong>{username}</strong> a supprimé {field} de <i>{oldValue}</i>'
tickets.events.impact: '<strong>{username}</strong> a changé l’impact de <i>{oldValue}</i> à <i>{newValue}</i>'
tickets.events.ongoing_contract.changed: '<strong>{username}</strong> a changé le contrat en cours de <i>{oldValue}</i> à <i>{newValue}</i>'
tickets.events.ongoing_contract.new: '<strong>{username}</strong> a changé le contrat en cours à <i>{newValue}</i>'
tickets.events.ongoing_contract.removed: '<strong>{username}</strong> a retiré le contrat en cours <i>{oldValue}</i>'
tickets.events.priority: '<strong>{username}</strong> a changé la priorité de <i>{oldValue}</i> à <i>{newValue}</i>'
tickets.events.requester: '<strong>{username}</strong> a changé le demandeur de <i>{oldValue}</i> à <i>{newValue}</i>'
tickets.events.solution.changed: '<strong>{username}</strong> a changé la solution'
Expand Down

0 comments on commit c4da224

Please sign in to comment.