Skip to content

Commit

Permalink
new: Allow to extend the hours in the contracts
Browse files Browse the repository at this point in the history
  • Loading branch information
marien-probesys committed Nov 21, 2023
1 parent 9b1afa7 commit 1895e83
Show file tree
Hide file tree
Showing 6 changed files with 271 additions and 3 deletions.
72 changes: 72 additions & 0 deletions src/Controller/Contracts/MaxHoursController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

// This file is part of Bileto.
// Copyright 2022-2023 Probesys
// SPDX-License-Identifier: AGPL-3.0-or-later

namespace App\Controller\Contracts;

use App\Controller\BaseController;
use App\Entity\Contract;
use App\Repository\ContractRepository;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Translation\TranslatorInterface;

class MaxHoursController extends BaseController
{
#[Route('/contracts/{uid}/max-hours/edit', name: 'edit contract max hours', methods: ['GET', 'HEAD'])]
public function edit(
Contract $contract,
Request $request,
): Response {
$organization = $contract->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(),
]);
}
}
46 changes: 46 additions & 0 deletions templates/contracts/max_hours/edit.html.twig
Original file line number Diff line number Diff line change
@@ -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 %}
<form
action="{{ path('update contract max hours', {'uid': contract.uid}) }}"
method="post"
class="wrapper wrapper--small wrapper--center flow"
>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('update contract max hours') }}">

{% if error %}
{{ include('alerts/_error.html.twig', { message: error }, with_context = false) }}
{% endif %}

<div class="flow flow--small">
<label for="additional-hours">
{{ 'contracts.max_hours.edit.additional_hours' | trans }}
</label>

<input
type="number"
id="additional-hours"
name="additionalHours"
value="{{ additionalHours }}"
min="0"
step="1"
required
class="input--size3"
/>
</div>

<div class="form__actions">
<button class="button--primary" type="submit">
{{ 'forms.save_changes' | trans }}
</button>
</div>
</form>
{% endblock %}
17 changes: 14 additions & 3 deletions templates/organizations/contracts/show.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,20 @@
{% endif %}

<div class="flow flow--small">
<span class="text--small" id="contract-hours-consumed">
{{ 'contracts.hours_consumed' | trans({ 'hours': contract.consumedMinutes | formatMinutes, 'maxHours': contract.maxHours }) | raw }}
</span>
<div class="row row--center row--always flow text--small">
<div class="row__item--extend" id="contract-hours-consumed">
{{ 'contracts.hours_consumed' | trans({ 'hours': contract.consumedMinutes | formatMinutes, 'maxHours': contract.maxHours }) | raw }}
</div>

<button
class="button--discreet"
data-controller="modal-opener"
data-action="modal-opener#fetch"
data-modal-opener-href-value="{{ path('edit contract max hours', { uid: contract.uid }) }}"
>
{{ 'contracts.show.extend' | trans }}
</button>
</div>

<progress value="{{ contract.consumedHours }}" max="{{ contract.maxHours }}" aria-labelledby="contract-hours-consumed">
</progress>
Expand Down
133 changes: 133 additions & 0 deletions tests/Controller/Contracts/MaxHoursControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

// This file is part of Bileto.
// Copyright 2022-2023 Probesys
// SPDX-License-Identifier: AGPL-3.0-or-later

namespace App\Tests\Controller\Tickets;

use App\Tests\AuthorizationHelper;
use App\Tests\Factory\ContractFactory;
use App\Tests\Factory\UserFactory;
use App\Tests\SessionHelper;
use App\Utils;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;

class MaxHoursControllerTest extends WebTestCase
{
use AuthorizationHelper;
use Factories;
use ResetDatabase;
use SessionHelper;

public function testGetEditRendersCorrectly(): void
{
$client = static::createClient();
$user = UserFactory::createOne();
$client->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,
]);
}
}
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 @@ -56,13 +56,16 @@ 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).'
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'
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 @@ -56,13 +56,16 @@ 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).'
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'
Expand Down

0 comments on commit 1895e83

Please sign in to comment.