restart via API allows to copy description (#782)

This commit is contained in:
Kevin Papst
2019-05-13 17:32:01 +02:00
committed by GitHub
parent 1f5710eaba
commit bbe1f9beda
28 changed files with 250 additions and 118 deletions

View File

@@ -80,7 +80,11 @@ export default class KimaiAPILink extends KimaiClickHandlerReducedInTableRow {
};
if (method === 'PATCH') {
API.patch(url, {}, successHandle, errorHandle);
let data = {};
if (attributes.payload) {
data = attributes.payload;
}
API.patch(url, data, successHandle, errorHandle);
} else if (method === 'DELETE') {
API.delete(url, successHandle, errorHandle);
}

View File

@@ -64,7 +64,12 @@ export default class KimaiRecentActivities extends KimaiPlugin {
.replace('%project%', timesheet.project.name)
.replace('%activity%', timesheet.activity.name);
htmlToInsert += `<li><a href="${ this.attributes['href'].replace('000', timesheet.id) }"><i class="${ this.attributes['icon'] }"></i> ${ label }</a></li>`;
htmlToInsert +=
`<li>` +
`<a href="${ this.attributes['href'].replace('000', timesheet.id) }" data-event="kimai.timesheetStart kimai.timesheetUpdate" class="api-link" data-method="PATCH" data-msg-error="timesheet.start.error" data-msg-success="timesheet.start.success">` +
`<i class="${ this.attributes['icon'] }"></i> ${ label }` +
`</a>` +
`</li>`;
}
this.itemList.innerHTML = htmlToInsert;

View File

@@ -55,5 +55,4 @@ App\Entity\Timesheet:
getTags:
serialized_name: tags
exp: "object.getTagsAsArray()"
type: array
groups: [Default]
type: array<string>

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{
"build/app.js": "./app.js?b2225d72491af37dec94",
"build/app.js": "./app.js?f256658f0e056beff1e8",
"build/app.css": "./app.css?52d21a4cca9a4768126cc7e11f6a78e9",
"build/fonts/fa-solid-900.woff2": "./fonts/fa-solid-900.woff2?e8a92a29",
"build/images/fa-solid-900.svg": "./images/fa-solid-900.svg?666a82cb",

View File

@@ -13,6 +13,7 @@ namespace App\API;
use App\Configuration\TimesheetConfiguration;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Form\TimesheetEditForm;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TagRepository;
@@ -30,7 +31,9 @@ use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Validator\Constraints;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* @RouteResource("Timesheet")
@@ -527,4 +530,86 @@ class TimesheetController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Restarts a previously stopped timesheet record for the current user
*
* @SWG\Response(
* response=200,
* description="Restarts a timesheet record for the same customer, project, activity combination. The current user will be the owner of the new record. Kimai tries to stop running records, which is expected to fail depending on the configured rules. Data will be copied from the original record if requested.",
* @SWG\Schema(ref="#/definitions/TimesheetEntity")
* )
* @SWG\Parameter(
* name="id",
* in="path",
* type="integer",
* description="Timesheet record ID to restart",
* required=true,
* )
*
* @Rest\RequestParam(name="copy", requirements="all|tags|description", strict=true, nullable=true, description="Whether description and tags are copied to the new entry. Allowed values: all, tags, description (default: nothing is copied)")
*
* @Security("is_granted('start_own_timesheet') or is_granted('start_other_timesheet')")
*
* @param int $id
* @return Response
* @throws \App\Repository\RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function restartAction($id, ParamFetcherInterface $paramFetcher, ValidatorInterface $validator)
{
/** @var Timesheet $timesheet */
$timesheet = $this->repository->find($id);
/** @var User $user */
$user = $this->getUser();
if (null === $timesheet) {
throw new NotFoundException();
}
if (!$this->isGranted('start', $timesheet)) {
throw new AccessDeniedHttpException('You are not allowed to re-start this timesheet');
}
$entry = new Timesheet();
$entry
->setBegin($this->dateTime->createDateTime())
->setUser($user)
->setActivity($timesheet->getActivity())
->setProject($timesheet->getProject())
;
if (null !== ($copy = $paramFetcher->get('copy'))) {
if (in_array($copy, ['description', 'all'])) {
$entry->setDescription($timesheet->getDescription());
}
if (in_array($copy, ['tags', 'all'])) {
foreach ($timesheet->getTags() as $tag) {
$entry->addTag($tag);
}
}
}
$errors = $validator->validate($entry);
if (count($errors) > 0) {
throw new BadRequestHttpException($errors[0]->getPropertyPath() . ' = ' . $errors[0]->getMessage());
}
$this->repository->stopActiveEntries(
$user,
$this->configuration->getActiveEntriesHardLimit()
);
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($entry);
$entityManager->flush();
$view = new View($entry, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
return $this->viewHandler->handle($view);
}
}

View File

@@ -21,7 +21,6 @@ use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Controller used to manage timesheets.
@@ -126,6 +125,8 @@ class TimesheetController extends AbstractController
}
/**
* Used for the initial page rendering.
*
* @return \Symfony\Component\HttpFoundation\Response
*/
public function activeEntriesAction()
@@ -142,44 +143,6 @@ class TimesheetController extends AbstractController
);
}
/**
* @Route(path="/start/{id}", name="timesheet_start", requirements={"id" = "\d+"}, methods={"GET", "POST"})
* @Security("is_granted('start', timesheet)")
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function startAction(ValidatorInterface $validator, Timesheet $timesheet)
{
$user = $this->getUser();
try {
$entry = new Timesheet();
$entry
->setBegin($this->dateTime->createDateTime())
->setUser($user)
->setActivity($timesheet->getActivity())
->setProject($timesheet->getProject());
$errors = $validator->validate($entry);
if (count($errors) > 0) {
$this->flashError('timesheet.start.error', ['%reason%' => $errors[0]->getPropertyPath() . ' = ' . $errors[0]->getMessage()]);
} else {
$this->stopActiveEntries($user);
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($entry);
$entityManager->flush();
$this->flashSuccess('timesheet.start.success');
}
} catch (\Exception $ex) {
$this->flashError('timesheet.start.error', ['%reason%' => $ex->getMessage()]);
}
return $this->redirectToRoute('timesheet');
}
/**
* @Route(path="/{id}/edit", name="timesheet_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', entry)")

View File

@@ -45,14 +45,6 @@ trait TimesheetControllerTrait
$this->configuration = $configuration;
}
/**
* @return int
*/
protected function getHardLimit()
{
return $this->configuration->getActiveEntriesHardLimit();
}
/**
* @return int
*/
@@ -163,14 +155,17 @@ trait TimesheetControllerTrait
try {
if (null === $entry->getEnd()) {
$this->stopActiveEntries($entry->getUser());
$this->getRepository()->stopActiveEntries(
$entry->getUser(),
$this->configuration->getActiveEntriesHardLimit()
);
}
$entityManager->persist($entry);
$entityManager->flush();
$this->flashSuccess('action.update.success');
} catch (\Exception $ex) {
$this->flashError('timesheet.start.error', ['%reason%' => $ex->getMessage()]);
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
return $this->redirectToRoute($redirectRoute);
@@ -182,17 +177,6 @@ trait TimesheetControllerTrait
]);
}
/**
* @param User $user
* @throws \App\Repository\RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
protected function stopActiveEntries(User $user)
{
$this->getRepository()->stopActiveEntries($user, $this->getHardLimit());
}
/**
* @param Timesheet $entry
* @param string $redirectRoute

View File

@@ -257,8 +257,8 @@ class TimesheetEditForm extends AbstractType
->add('tags', TagsInputType::class, [
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'text',
'description' => 'Tags for timesheet entry',
'type' => 'string',
'description' => 'Comma separated list of tags for this timesheet record',
],
'required' => false,
]);

View File

@@ -193,6 +193,9 @@
'lastYear': '{{ 'daterangepicker.lastYear'|trans({}, 'daterangepicker') }}',
'thisYear': '{{ 'daterangepicker.thisYear'|trans({}, 'daterangepicker') }}',
'customRange': '{{ 'daterangepicker.customRange'|trans({}, 'daterangepicker') }}',
'timesheet.start.success': '{{ 'timesheet.start.success'|trans({}, 'flashmessages') }}',
'timesheet.start.error': '{{ 'timesheet.start.error'|trans({}, 'flashmessages') }}',
'timesheet.start.exceeded_limit': '{{ 'timesheet.start.exceeded_limit'|trans({}, 'flashmessages') }}',
'timesheet.stop.success': '{{ 'timesheet.stop.success'|trans({}, 'flashmessages') }}',
'timesheet.stop.error': '{{ 'timesheet.stop.error'|trans({}, 'flashmessages') }}',
'action.update.success': '{{ 'action.update.success'|trans({}, 'flashmessages') }}',

View File

@@ -219,7 +219,7 @@
{% endif %}
{% if timesheet.end and is_granted('start', timesheet) %}
{% set actions = actions|merge({'repeat': path('timesheet_start', {'id' : timesheet.id})}) %}
{% 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('edit', timesheet) %}
@@ -274,7 +274,7 @@
{% endif %}
{% if timesheet.end and is_granted('start', timesheet) %}
{% set actions = actions|merge({'repeat': path('timesheet_start', {'id' : timesheet.id})}) %}
{% 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('edit', timesheet) %}

View File

@@ -253,8 +253,8 @@
target="{{ target }}"
{%- endif -%}
{%- if attr is not empty -%}
{%- for name, value in attr -%}
{{ name }}="{{ value }}"
{%- for name, value in attr %}
{{- ' ' ~ name }}={% if '"' in value %}'{{ value|raw }}'{% else %}"{{ value|raw }}"{% endif %}
{%- endfor -%}
{%- endif -%}
>{% if title is not null %}{{ title }}{% else %}{{ macro.icon(icon) }}{% endif %}</a>

View File

@@ -5,7 +5,7 @@
</a>
<ul class="dropdown-menu"
data-api="{{ path('recent_timesheet', {'size' : 10}) }}"
data-href="{{ path('timesheet_start', {'id' : '000'}) }}"
data-href="{{ path('restart_timesheet', {'id' : '000'}) }}"
data-icon="{{ 'start-small'|icon }}"
data-template="{{ 'recent.activities.format'|trans }}">
<li class="header">{{ 'recent.activities'|trans }}</li>

View File

@@ -198,6 +198,32 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
);
}
/**
* @param string $role
* @param string $url
* @param array $data
*/
protected function assertEntityNotFoundForDelete(string $role, string $url, array $data)
{
$client = $this->getClientForAuthenticatedUser($role);
$this->request($client, $url, 'DELETE', [], json_encode($data));
$response = $client->getResponse();
$this->assertFalse($response->isSuccessful());
$expected = [
'code' => 404,
'message' => 'Not found'
];
$this->assertEquals(404, $client->getResponse()->getStatusCode());
$this->assertEquals(
$expected,
json_decode($client->getResponse()->getContent(), true)
);
}
/**
* @param Response $response
* @param string $message

View File

@@ -86,4 +86,9 @@ class TagControllerTest extends APIControllerBaseTest
$this->assertEquals(9, count($result));
}
public function testDeleteActionWithUnknownTimesheet()
{
$this->assertEntityNotFoundForDelete(User::ROLE_ADMIN, '/api/tags/255', []);
}
}

View File

@@ -430,16 +430,15 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertEntityNotFound(User::ROLE_USER, '/api/timesheets/' . $id);
}
public function testDeleteActionWithUnknownTimesheet()
{
$this->assertEntityNotFoundForDelete(User::ROLE_ADMIN, '/api/timesheets/255', []);
}
public function testDeleteActionForDifferentUser()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/api/timesheets/1');
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result);
$this->assertDefaultStructure($result);
$this->assertNotEmpty($result['id']);
$id = $result['id'];
$id = 1;
$this->request($client, '/api/timesheets/' . $id, 'DELETE');
$this->assertTrue($client->getResponse()->isSuccessful());
@@ -631,6 +630,91 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertDefaultStructure($result[0], false);
}
public function testRestartAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$data = [
'description' => 'foo',
'tags' => 'another,testing,bar'
];
$this->request($client, '/api/timesheets/1', 'PATCH', [], json_encode($data));
$this->request($client, '/api/timesheets/1/restart', 'PATCH');
$this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertDefaultStructure($result, true);
$this->assertEmpty($result['description']);
$this->assertEmpty($result['tags']);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var Timesheet $timesheet */
$timesheet = $em->getRepository(Timesheet::class)->find($result['id']);
$this->assertInstanceOf(\DateTime::class, $timesheet->getBegin());
$this->assertNull($timesheet->getEnd());
$this->assertEquals(1, $timesheet->getActivity()->getId());
$this->assertEquals(1, $timesheet->getProject()->getId());
$this->assertEmpty($timesheet->getDescription());
$this->assertEmpty($timesheet->getTags());
}
public function testRestartActionWithCopyData()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$data = [
'description' => 'foo',
'tags' => 'another,testing,bar'
];
$this->request($client, '/api/timesheets/1', 'PATCH', [], json_encode($data));
$this->assertTrue($client->getResponse()->isSuccessful());
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$timesheet = $em->getRepository(Timesheet::class)->find(1);
$this->assertEquals('foo', $timesheet->getDescription());
$this->request($client, '/api/timesheets/1/restart', 'PATCH', ['copy' => 'all']);
$this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertDefaultStructure($result, true);
$this->assertEquals('foo', $result['description']);
$this->assertEquals(['another', 'testing', 'bar'], $result['tags']);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var Timesheet $timesheet */
$timesheet = $em->getRepository(Timesheet::class)->find($result['id']);
$this->assertInstanceOf(\DateTime::class, $timesheet->getBegin());
$this->assertNull($timesheet->getEnd());
$this->assertEquals(1, $timesheet->getActivity()->getId());
$this->assertEquals(1, $timesheet->getProject()->getId());
$this->assertEquals('foo', $timesheet->getDescription());
$this->assertEquals(['another', 'testing', 'bar'], $timesheet->getTagsAsArray());
}
public function testRestartNotAllowedForUser()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$start = new \DateTime('-10 days');
$fixture = new TimesheetFixtures();
$fixture
->setFixedRate(true)
->setHourlyRate(true)
->setAmount(2)
->setUser($this->getUserByRole($em, User::ROLE_ADMIN))
->setStartDate($start)
->setAmountRunning(3)
;
$this->importFixture($em, $fixture);
$this->request($client, '/api/timesheets/12/restart', 'PATCH');
$this->assertApiResponseAccessDenied($client->getResponse(), 'You are not allowed to re-start this timesheet');
}
protected function assertDefaultStructure(array $result, $full = true)
{
$expectedKeys = [

View File

@@ -143,32 +143,6 @@ class TimesheetControllerTest extends ControllerBaseTest
$this->assertNull($timesheet->getFixedRate());
}
public function testStartAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new TimesheetFixtures();
$fixture->setUser($this->getUserByRole($em, User::ROLE_USER));
$fixture->setAmount(1);
$this->importFixture($em, $fixture);
$this->request($client, '/timesheet/start/1');
$this->assertIsRedirect($client, $this->createUrl('/timesheet/'));
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasFlashSuccess($client, 'Time recording was started');
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var Timesheet $timesheet */
$timesheet = $em->getRepository(Timesheet::class)->find(2);
$this->assertInstanceOf(\DateTime::class, $timesheet->getBegin());
$this->assertNull($timesheet->getEnd());
$this->assertEquals(1, $timesheet->getActivity()->getId());
$this->assertEquals(1, $timesheet->getProject()->getId());
}
public function testCreateActionDoesNotShowRateFieldsForUser()
{
$client = $this->getClientForAuthenticatedUser();

View File

@@ -16,7 +16,7 @@
</trans-unit>
<trans-unit id="timesheet.start.error">
<source>timesheet.start.error</source>
<target>تعذر بدء تسجيل الوقت:٪ reason٪</target>
<target>تعذر بدء تسجيل الوقت</target>
</trans-unit>
<trans-unit id="action.update.success">
<source>action.update.success</source>

View File

@@ -16,11 +16,11 @@
</trans-unit>
<trans-unit id="timesheet.start.error">
<source>timesheet.start.error</source>
<target>Zeitmessung konnte nicht gestartet werden: %reason%</target>
<target>Zeitmessung konnte nicht gestartet werden</target>
</trans-unit>
<trans-unit id="timesheet.start.exceeded_limit">
<source>timesheet.start.exceeded_limit</source>
<target>das Limit aktiver Zeitmessungen wurde erreicht, bitte stoppen Sie mindestens eine laufende Zeitmessung.</target>
<target>Das Limit aktiver Zeitmessungen wurde erreicht, bitte stoppen Sie mindestens eine laufende Zeitmessung.</target>
</trans-unit>
<trans-unit id="action.update.success">
<source>action.update.success</source>

View File

@@ -16,7 +16,7 @@
</trans-unit>
<trans-unit id="timesheet.start.error">
<source>timesheet.start.error</source>
<target>Time recording could not be started: %reason%</target>
<target>Time recording could not be started</target>
</trans-unit>
<trans-unit id="timesheet.start.exceeded_limit">
<source>timesheet.start.exceeded_limit</source>

View File

@@ -16,7 +16,7 @@
</trans-unit>
<trans-unit id="timesheet.start.error">
<source>timesheet.start.error</source>
<target>No se pudo iniciar el registro de tiempo: %reason%</target>
<target>No se pudo iniciar el registro de tiempo</target>
</trans-unit>
<trans-unit id="action.update.success">
<source>action.update.success</source>

View File

@@ -16,7 +16,7 @@
</trans-unit>
<trans-unit id="timesheet.start.error">
<source>timesheet.start.error</source>
<target>Le chronométrage ne peut pas être lancé : %reason%</target>
<target>Le chronométrage ne peut pas être lancé</target>
</trans-unit>
<trans-unit id="action.update.success">
<source>action.update.success</source>

View File

@@ -16,7 +16,7 @@
</trans-unit>
<trans-unit id="timesheet.start.error">
<source>timesheet.start.error</source>
<target>Az idő rögzítését nem lehet elindítani a következő okból: %reason%</target>
<target>Az idő rögzítését nem lehet elindítani a következő okból</target>
</trans-unit>
<trans-unit id="timesheet.start.exceeded_limit">
<source>timesheet.start.exceeded_limit</source>

View File

@@ -16,7 +16,7 @@
</trans-unit>
<trans-unit id="timesheet.start.error">
<source>timesheet.start.error</source>
<target>Time-recording non può essere avviata: %reason%</target>
<target>Time-recording non può essere avviata</target>
</trans-unit>
<trans-unit id="action.update.success">
<source>action.update.success</source>

View File

@@ -16,7 +16,7 @@
</trans-unit>
<trans-unit id="timesheet.start.error">
<source>timesheet.start.error</source>
<target>時間の記録を開始できませんでした: %reason%</target>
<target>時間の記録を開始できませんでした</target>
</trans-unit>
<trans-unit id="timesheet.start.exceeded_limit">
<source>timesheet.start.exceeded_limit</source>

View File

@@ -16,7 +16,7 @@
</trans-unit>
<trans-unit id="timesheet.start.error">
<source>timesheet.start.error</source>
<target>A gravação de tempo não pôde ser iniciada: %reason%</target>
<target>A gravação de tempo não pôde ser iniciada</target>
</trans-unit>
<trans-unit id="action.update.success">
<source>action.update.success</source>

View File

@@ -16,7 +16,7 @@
</trans-unit>
<trans-unit id="timesheet.start.error">
<source>timesheet.start.error</source>
<target>Ошибка - Хронометраж не был начат: %reason%</target>
<target>Ошибка - Хронометраж не был начат</target>
</trans-unit>
<trans-unit id="action.update.success">
<source>action.update.success</source>

View File

@@ -16,7 +16,7 @@
</trans-unit>
<trans-unit id="timesheet.start.error">
<source>timesheet.start.error</source>
<target state="translated">Tidinspelning kunde inte startas: %reason%</target>
<target state="translated">Tidinspelning kunde inte startas</target>
</trans-unit>
<trans-unit id="timesheet.start.exceeded_limit">
<source>timesheet.start.exceeded_limit</source>