diff --git a/src/API/TimesheetController.php b/src/API/TimesheetController.php index a61aa181..4572b8da 100644 --- a/src/API/TimesheetController.php +++ b/src/API/TimesheetController.php @@ -656,6 +656,47 @@ class TimesheetController extends BaseApiController return $this->viewHandler->handle($view); } + /** + * Duplicates an existing timesheet record + * + * @SWG\Response( + * response=200, + * description="Duplicates a timesheet record, resetting the export state only.", + * @SWG\Schema(ref="#/definitions/TimesheetEntity") + * ) + * @SWG\Parameter( + * name="id", + * in="path", + * type="integer", + * description="Timesheet record ID to duplicate", + * required=true, + * ) + * + * @ApiSecurity(name="apiUser") + * @ApiSecurity(name="apiToken") + */ + public function duplicateAction(int $id): Response + { + $timesheet = $this->repository->find($id); + + if (null === $timesheet) { + throw new NotFoundException(); + } + + if (!$this->isGranted('duplicate', $timesheet)) { + throw new AccessDeniedHttpException('You are not allowed to duplicate this timesheet'); + } + + $copyTimesheet = clone $timesheet; + + $this->service->saveNewTimesheet($copyTimesheet); + + $view = new View($copyTimesheet, 200); + $view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']); + + return $this->viewHandler->handle($view); + } + /** * Switch the export state of a timesheet record to (un-)lock it * diff --git a/src/Entity/Timesheet.php b/src/Entity/Timesheet.php index 17d7fcfa..6d79706e 100644 --- a/src/Entity/Timesheet.php +++ b/src/Entity/Timesheet.php @@ -45,7 +45,7 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface public const CATEGORY_WORK = 'work'; /** - * @var int + * @var int|null * * @ORM\Column(name="id", type="integer") * @ORM\Id @@ -537,4 +537,12 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface return $this; } + + public function __clone() + { + if ($this->id) { + $this->id = null; + $this->exported = false; + } + } } diff --git a/src/Voter/TimesheetVoter.php b/src/Voter/TimesheetVoter.php index 5c293d5a..4cf503bd 100644 --- a/src/Voter/TimesheetVoter.php +++ b/src/Voter/TimesheetVoter.php @@ -41,6 +41,7 @@ class TimesheetVoter extends AbstractVoter self::VIEW_RATE, self::EDIT_RATE, self::EDIT_EXPORT, + 'duplicate' ]; /** @@ -99,6 +100,10 @@ class TimesheetVoter extends AbstractVoter $permission .= $attribute; break; + case 'duplicate': + $permission = self::EDIT; + break; + case self::VIEW_RATE: case self::EDIT_RATE: case self::STOP: diff --git a/templates/timesheet/actions.html.twig b/templates/timesheet/actions.html.twig index 0d767ec0..c258a5c2 100644 --- a/templates/timesheet/actions.html.twig +++ b/templates/timesheet/actions.html.twig @@ -39,6 +39,10 @@ {% set actions = actions|merge({'repeat': {'url': path('restart_timesheet', {'id' : timesheet.id}), 'class': 'api-link', 'attr': {'data-payload': '{"copy": "all"}', 'data-event': 'kimai.timesheetStart kimai.timesheetUpdate', 'data-method': 'PATCH', 'data-msg-error': 'timesheet.start.error', 'data-msg-success': 'timesheet.start.success'}}}) %} {% endif %} + {% if is_granted('duplicate', timesheet) %} + {% set actions = actions|merge({'copy': {'url': path('duplicate_timesheet', {'id' : timesheet.id}), 'class': 'api-link', 'attr': {'data-payload': '{"copy": "all"}', 'data-event': 'kimai.timesheetStart kimai.timesheetUpdate', 'data-method': 'PATCH', 'data-msg-error': 'timesheet.update.error', 'data-msg-success': 'timesheet.update.success'}}}) %} + {% endif %} + {% if is_granted('edit', timesheet) %} {% set class = '' %} {% if view != 'edit' %} diff --git a/tests/API/TimesheetControllerTest.php b/tests/API/TimesheetControllerTest.php index 1b1b1dce..7561c887 100644 --- a/tests/API/TimesheetControllerTest.php +++ b/tests/API/TimesheetControllerTest.php @@ -786,6 +786,44 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertEntityNotFoundForPatch(User::ROLE_USER, '/api/timesheets/42/restart', []); } + public function testDuplicateAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $data = [ + 'activity' => 1, + 'project' => 1, + 'begin' => ($this->dateTime->createDateTime('- 16 hours'))->format('Y-m-d H:m:0'), + 'end' => ($this->dateTime->createDateTime())->format('Y-m-d H:m:0'), + 'description' => 'foo', + 'fixedRate' => 2016, + 'hourlyRate' => 127 + ]; + $this->request($client, '/api/timesheets', 'POST', [], json_encode($data)); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $result = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($result); + $this->assertDefaultStructure($result); + $this->assertNotEmpty($result['id']); + $this->assertTrue($result['duration'] == 57600 || $result['duration'] == 57660); // 1 minute rounding might be applied + $this->assertEquals(2016, $result['rate']); + + $this->request($client, '/api/timesheets/' . $result['id'] . '/duplicate', 'PATCH'); + $this->assertTrue($client->getResponse()->isSuccessful()); + + $result = json_decode($client->getResponse()->getContent(), true); + $this->assertIsArray($result); + $this->assertDefaultStructure($result); + $this->assertNotEmpty($result['id']); + $this->assertTrue($result['duration'] == 57600 || $result['duration'] == 57660); // 1 minute rounding might be applied + $this->assertEquals(2016, $result['rate']); + } + + public function testDuplicateThrowsNotFound() + { + $this->assertEntityNotFoundForPatch(User::ROLE_ADMIN, '/api/timesheets/11/duplicate', []); + } + public function testExportAction() { $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);