diff --git a/src/Controller/Contracts/MaxHoursController.php b/src/Controller/Contracts/MaxHoursController.php new file mode 100644 index 00000000..80860760 --- /dev/null +++ b/src/Controller/Contracts/MaxHoursController.php @@ -0,0 +1,72 @@ +getOrganization(); + + $this->denyAccessUnlessGranted('orga:manage:contracts', $organization); + + return $this->render('contracts/max_hours/edit.html.twig', [ + 'organization' => $organization, + 'contract' => $contract, + 'additionalHours' => 0, + ]); + } + + #[Route('/contracts/{uid}/max-hours/edit', name: 'update contract max hours', methods: ['POST'])] + public function update( + Contract $contract, + Request $request, + ContractRepository $contractRepository, + TranslatorInterface $translator, + ): Response { + $organization = $contract->getOrganization(); + + $this->denyAccessUnlessGranted('orga:manage:contracts', $organization); + + $additionalHours = $request->request->getInt('additionalHours'); + $csrfToken = $request->request->getString('_csrf_token'); + + if ($additionalHours < 0) { + $additionalHours = 0; + } + + if (!$this->isCsrfTokenValid('update contract max hours', $csrfToken)) { + return $this->renderBadRequest('contracts/max_hours/edit.html.twig', [ + 'organization' => $organization, + 'contract' => $contract, + 'additionalHours' => $additionalHours, + 'error' => $translator->trans('csrf.invalid', [], 'errors'), + ]); + } + + $maxHours = $contract->getMaxHours() + $additionalHours; + $contract->setMaxHours($maxHours); + + $contractRepository->save($contract, true); + + return $this->redirectToRoute('organization contract', [ + 'uid' => $organization->getUid(), + 'contract_uid' => $contract->getUid(), + ]); + } +} diff --git a/templates/contracts/max_hours/edit.html.twig b/templates/contracts/max_hours/edit.html.twig new file mode 100644 index 00000000..18dc1255 --- /dev/null +++ b/templates/contracts/max_hours/edit.html.twig @@ -0,0 +1,46 @@ +{# + # This file is part of Bileto. + # Copyright 2022-2023 Probesys + # SPDX-License-Identifier: AGPL-3.0-or-later + #} + +{% extends 'modal.html.twig' %} + +{% block title %}{{ 'contracts.max_hours.edit.title' | trans }}{% endblock %} + +{% block body %} +
+ + + {% if error %} + {{ include('alerts/_error.html.twig', { message: error }, with_context = false) }} + {% endif %} + +
+ + + +
+ +
+ +
+
+{% endblock %} diff --git a/templates/organizations/contracts/show.html.twig b/templates/organizations/contracts/show.html.twig index 5b95349b..441893b6 100644 --- a/templates/organizations/contracts/show.html.twig +++ b/templates/organizations/contracts/show.html.twig @@ -49,9 +49,20 @@ {% endif %}
- - {{ 'contracts.hours_consumed' | trans({ 'hours': contract.consumedMinutes | formatMinutes, 'maxHours': contract.maxHours }) | raw }} - +
+
+ {{ 'contracts.hours_consumed' | trans({ 'hours': contract.consumedMinutes | formatMinutes, 'maxHours': contract.maxHours }) | raw }} +
+ + +
diff --git a/tests/Controller/Contracts/MaxHoursControllerTest.php b/tests/Controller/Contracts/MaxHoursControllerTest.php new file mode 100644 index 00000000..ca8c4a6e --- /dev/null +++ b/tests/Controller/Contracts/MaxHoursControllerTest.php @@ -0,0 +1,133 @@ +loginUser($user->object()); + $this->grantOrga($user->object(), ['orga:manage:contracts']); + $contract = ContractFactory::createOne(); + + $client->request('GET', "/contracts/{$contract->getUid()}/max-hours/edit"); + + $this->assertResponseIsSuccessful(); + $this->assertSelectorTextContains('h1', 'Extend the number of hours of the contract'); + } + + public function testGetEditFailsIfAccessIsForbidden(): void + { + $this->expectException(AccessDeniedException::class); + + $client = static::createClient(); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $contract = ContractFactory::createOne(); + + $client->catchExceptions(false); + $client->request('GET', "/contracts/{$contract->getUid()}/max-hours/edit"); + } + + public function testPostUpdateSavesTheMaxHours(): void + { + $client = static::createClient(); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $this->grantOrga($user->object(), ['orga:manage:contracts']); + $contract = ContractFactory::createOne([ + 'maxHours' => 10, + ]); + + $client->request('POST', "/contracts/{$contract->getUid()}/max-hours/edit", [ + '_csrf_token' => $this->generateCsrfToken($client, 'update contract max hours'), + 'additionalHours' => 5, + ]); + + $organization = $contract->getOrganization(); + $this->assertResponseRedirects("/organizations/{$organization->getUid()}/contracts/{$contract->getUid()}", 302); + $contract->refresh(); + $this->assertSame(15, $contract->getMaxHours()); + } + + public function testPostUpdateDoesNotAcceptNegativeAdditionalHours(): void + { + $client = static::createClient(); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $this->grantOrga($user->object(), ['orga:manage:contracts']); + $contract = ContractFactory::createOne([ + 'maxHours' => 10, + ]); + + $client->request('POST', "/contracts/{$contract->getUid()}/max-hours/edit", [ + '_csrf_token' => $this->generateCsrfToken($client, 'update contract max hours'), + 'additionalHours' => -5, + ]); + + $organization = $contract->getOrganization(); + $this->assertResponseRedirects("/organizations/{$organization->getUid()}/contracts/{$contract->getUid()}", 302); + $contract->refresh(); + $this->assertSame(10, $contract->getMaxHours()); + } + + public function testPostUpdateFailsIfCsrfTokenIsInvalid(): void + { + $client = static::createClient(); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $this->grantOrga($user->object(), ['orga:manage:contracts']); + $contract = ContractFactory::createOne([ + 'maxHours' => 10, + ]); + + $client->request('POST', "/contracts/{$contract->getUid()}/max-hours/edit", [ + '_csrf_token' => 'not the token', + 'additionalHours' => 5, + ]); + + $organization = $contract->getOrganization(); + $this->assertSelectorTextContains('[data-test="alert-error"]', 'The security token is invalid'); + $contract->refresh(); + $this->assertSame(10, $contract->getMaxHours()); + } + + public function testPostUpdateFailsIfAccessIsForbidden(): void + { + $this->expectException(AccessDeniedException::class); + + $client = static::createClient(); + $user = UserFactory::createOne(); + $client->loginUser($user->object()); + $contract = ContractFactory::createOne([ + 'maxHours' => 10, + ]); + + $client->catchExceptions(false); + $client->request('POST', "/contracts/{$contract->getUid()}/max-hours/edit", [ + '_csrf_token' => $this->generateCsrfToken($client, 'update contract max hours'), + 'additionalHours' => 5, + ]); + } +} diff --git a/translations/messages+intl-icu.en_GB.yaml b/translations/messages+intl-icu.en_GB.yaml index c3d2de5d..fe78ba70 100644 --- a/translations/messages+intl-icu.en_GB.yaml +++ b/translations/messages+intl-icu.en_GB.yaml @@ -56,6 +56,8 @@ contracts.index.number: '{count, plural, =0 {No contracts} one {1 contract} othe contracts.index.tickets: '{count, plural, =0 {No tickets} one {1 ticket} other {# tickets}}' contracts.index.title: Contracts contracts.max_hours: 'Max number of hours' +contracts.max_hours.edit.additional_hours: 'Number of additional hours' +contracts.max_hours.edit.title: 'Extend the number of hours of the contract' contracts.name: Name contracts.new.billing_interval: 'Billing interval in minutes' contracts.new.billing_interval.caption: 'The time spent on tickets will be rounded up for billing purposes (e.g. for 15 minutes of time spent, 30 minutes will be deducted for a corresponding billing interval).' @@ -63,6 +65,7 @@ contracts.new.submit: 'Create the contract' contracts.new.title: 'New contract' contracts.notes: 'Private notes' contracts.show.billing_interval: 'Billing interval: {count, plural, one {1 minute} other {# minutes}}' +contracts.show.extend: Extend contracts.show.notes_confidential: confidential contracts.show.renew: Renew contracts.start_at: 'Start on' diff --git a/translations/messages+intl-icu.fr_FR.yaml b/translations/messages+intl-icu.fr_FR.yaml index 4458901f..435954c7 100644 --- a/translations/messages+intl-icu.fr_FR.yaml +++ b/translations/messages+intl-icu.fr_FR.yaml @@ -56,6 +56,8 @@ contracts.index.number: '{count, plural, =0 {Aucun contrat} one {1 contrat} othe contracts.index.tickets: '{count, plural, =0 {Aucun ticket} one {1 ticket} other {# tickets}}' contracts.index.title: Contrats contracts.max_hours: 'Nombre max. d’heures' +contracts.max_hours.edit.additional_hours: 'Nombre d’heures supplémentaires' +contracts.max_hours.edit.title: 'Rallonger le nombre d’heures du contrat' contracts.name: Nom contracts.new.billing_interval: 'Intervalle de facturation en minutes' contracts.new.billing_interval.caption: 'Le temps passé sur les tickets sera arrondi pour le décompte de la facturation (ex. pour 15 minutes de temps passés, 30 minutes seront décomptés pour un intervalle de facturation correspondant).' @@ -63,6 +65,7 @@ contracts.new.submit: 'Créer le contrat' contracts.new.title: 'Nouveau contrat' contracts.notes: 'Notes privées' contracts.show.billing_interval: "Intervalle de facturation\_: {count, plural, one {1 minute} other {# minutes}}" +contracts.show.extend: Rallonger contracts.show.notes_confidential: confidentielles contracts.show.renew: Renouveler contracts.start_at: 'Démarre le'