export team timesheets (#508)

This commit is contained in:
Kevin Papst
2019-01-19 00:25:00 +01:00
committed by GitHub
parent 1ad440f488
commit a609cbf84a
11 changed files with 199 additions and 14 deletions

View File

@@ -62,7 +62,7 @@ kimai:
INVOICE: [view_invoice,create_invoice] INVOICE: [view_invoice,create_invoice]
INVOICE_TEMPLATE: [view_invoice_template,create_invoice_template,edit_invoice_template,delete_invoice_template] INVOICE_TEMPLATE: [view_invoice_template,create_invoice_template,edit_invoice_template,delete_invoice_template]
TIMESHEET: [view_own_timesheet,start_own_timesheet,stop_own_timesheet,create_own_timesheet,edit_own_timesheet,export_own_timesheet,delete_own_timesheet] TIMESHEET: [view_own_timesheet,start_own_timesheet,stop_own_timesheet,create_own_timesheet,edit_own_timesheet,export_own_timesheet,delete_own_timesheet]
TIMESHEET_OTHER: [view_other_timesheet,start_other_timesheet,stop_other_timesheet,create_other_timesheet,edit_other_timesheet,delete_other_timesheet] TIMESHEET_OTHER: [view_other_timesheet,start_other_timesheet,stop_other_timesheet,create_other_timesheet,edit_other_timesheet,export_other_timesheet,delete_other_timesheet]
PROFILE: [view_own_profile,edit_own_profile,password_own_profile,preferences_own_profile,api-token_own_profile] PROFILE: [view_own_profile,edit_own_profile,password_own_profile,preferences_own_profile,api-token_own_profile]
PROFILE_OTHER: [view_other_profile,edit_other_profile,delete_other_profile,password_other_profile,roles_other_profile,preferences_other_profile,api-token_other_profile] PROFILE_OTHER: [view_other_profile,edit_other_profile,delete_other_profile,password_other_profile,roles_other_profile,preferences_other_profile,api-token_other_profile]
USER: [view_user,create_user,delete_user] USER: [view_user,create_user,delete_user]

View File

@@ -78,6 +78,39 @@ class TimesheetController extends AbstractController
]); ]);
} }
/**
* @Route(path="/export", name="admin_timesheet_export", methods={"GET"})
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function exportAction(Request $request)
{
$query = new TimesheetQuery();
$query->setOrder(TimesheetQuery::ORDER_ASC);
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var TimesheetQuery $query */
$query = $form->getData();
if (null !== $query->getBegin()) {
$query->getBegin()->setTime(0, 0, 0);
}
if (null !== $query->getEnd()) {
$query->getEnd()->setTime(23, 59, 59);
}
}
/* @var $entries Pagerfanta */
$entries = $this->getRepository()->findByQuery($query);
return $this->render('admin/timesheet_export.html.twig', [
'entries' => $entries,
'query' => $query,
]);
}
/** /**
* @Route(path="/{id}/stop", name="admin_timesheet_stop", methods={"GET"}) * @Route(path="/{id}/stop", name="admin_timesheet_stop", methods={"GET"})
* @Security("is_granted('stop', entry)") * @Security("is_granted('stop', entry)")

View File

@@ -88,6 +88,7 @@ class TimesheetController extends AbstractController
public function exportAction(Request $request) public function exportAction(Request $request)
{ {
$query = new TimesheetQuery(); $query = new TimesheetQuery();
$query->setOrder(TimesheetQuery::ORDER_ASC);
$form = $this->getToolbarForm($query); $form = $this->getToolbarForm($query);
$form->handleRequest($request); $form->handleRequest($request);

View File

@@ -23,6 +23,7 @@ class TimesheetVoter extends AbstractVoter
public const STOP = 'stop'; public const STOP = 'stop';
public const EDIT = 'edit'; public const EDIT = 'edit';
public const DELETE = 'delete'; public const DELETE = 'delete';
public const EXPORT = 'export';
public const VIEW_RATE = 'view_rate'; public const VIEW_RATE = 'view_rate';
public const EDIT_RATE = 'edit_rate'; public const EDIT_RATE = 'edit_rate';
@@ -34,6 +35,7 @@ class TimesheetVoter extends AbstractVoter
self::STOP, self::STOP,
self::EDIT, self::EDIT,
self::DELETE, self::DELETE,
self::EXPORT,
self::VIEW_RATE, self::VIEW_RATE,
self::EDIT_RATE, self::EDIT_RATE,
]; ];
@@ -89,6 +91,7 @@ class TimesheetVoter extends AbstractVoter
case self::STOP: case self::STOP:
case self::EDIT: case self::EDIT:
case self::DELETE: case self::DELETE:
case self::EXPORT:
$permission .= $attribute; $permission .= $attribute;
break; break;

View File

@@ -6,7 +6,11 @@
{% block page_title %}{{ 'admin_timesheet.title'|trans }}{% endblock %} {% block page_title %}{{ 'admin_timesheet.title'|trans }}{% endblock %}
{% block page_subtitle %}{{ 'admin_timesheet.subtitle'|trans }}{% endblock %} {% block page_subtitle %}{{ 'admin_timesheet.subtitle'|trans }}{% endblock %}
{% block page_actions %} {% block page_actions %}
{% set actions = {'filter': '#collapseTimesheetAdmin', 'visibility': '#modal_timesheet_admin'} %} {% set actions = {'filter': '#collapseTimesheetAdmin'} %}
{% if is_granted('export_own_timesheet') %}
{% set actions = actions|merge({'download': 'onclick:return exportTimesheet()'}) %}
{% endif %}
{% set actions = actions|merge({'visibility': '#modal_timesheet_admin'}) %}
{% if is_granted('create_other_timesheet') %} {% if is_granted('create_other_timesheet') %}
{% set actions = actions|merge({'create': path('admin_timesheet_create')}) %} {% set actions = actions|merge({'create': path('admin_timesheet_create')}) %}
{% endif %} {% endif %}
@@ -98,3 +102,17 @@
{{ tables.data_table_footer(entries, 'admin_timesheet_paginated') }} {{ tables.data_table_footer(entries, 'admin_timesheet_paginated') }}
{% endblock %} {% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
function exportTimesheet() {
var form = $("div.toolbar form.navbar-form");
var prevAction = form.attr('action');
form.attr('target', '_blank').attr('action', '{{ path('admin_timesheet_export') }}');
form.submit();
form.removeAttr('target').attr('action', prevAction);
return false;
}
</script>
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% import "macros/widgets.html.twig" as widgets %}
{% extends 'invoice/layout.html.twig' %}
{% block invoice %}
<div class="row">
<div class="col-xs-12">
<h2 class="page-header">
<span contenteditable="true">
{% if query.begin is not empty and query.end is not empty %}
{% if query.begin|date('m') != query.end|date('m') or query.begin|date('Y') != query.end|date('Y') %}
{{ query.begin|date_short }} - {{ query.end|date_short }}
{% elseif query.end is not empty %}
{{ query.end|month_name|trans }} {{ query.end|date('Y') }}
{% elseif query.begin is not empty %}
{{ query.begin|month_name|trans }} {{ query.begin|date('Y') }}
{% endif %}
{% endif %}
{% if query.user is not empty %}
: {{ widgets.username(query.user) }}
{% endif %}
</span>
</h2>
</div>
</div>
<div class="row">
<div class="col-xs-12 table-responsive">
<table class="table">
<thead>
<tr>
<th>{{ 'label.date'|trans }}</th>
{% if query.user is empty %}
<th>{{ 'label.username'|trans }}</th>
{% endif %}
<th>{{ 'label.description'|trans }}</th>
<th>{{ 'label.hours'|trans }}</th>
</tr>
</thead>
<tbody>
{% set timeWorked = 0 %}
{% for entry in entries %}
{% set timeWorked = timeWorked + entry.duration %}
<tr>
<td>{{ entry.begin|date_short }}</td>
{% if query.user is empty %}
<td>{{ widgets.username(entry.user) }}</td>
{% endif %}
<td>
{% if entry.description is not empty %}
<div>
{{ entry.description|desc2html }}
</div>
{% endif %}
<span class="small">
{{ 'label.activity'|trans }}: {{ entry.activity.name }} |
{{ 'label.project'|trans }}: {{ entry.project.name }} |
{{ 'label.customer'|trans }}: {{ entry.project.customer.name }}
</span>
</td>
<td>{{ entry.duration|duration }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<th></th>
<th>{{ 'invoice.total_working_time'|trans }}</th>
<th>{{ timeWorked|duration }}</th>
</tr>
</tfoot>
</table>
</div>
</div>
{% endblock %}
{% block print_button %}{% endblock %}

View File

@@ -44,12 +44,20 @@ class ActivityControllerTest extends ControllerBaseTest
$this->assertNull($form->get('activity_edit_form[create_more]')->getValue()); $this->assertNull($form->get('activity_edit_form[create_more]')->getValue());
$client->submit($form, [ $client->submit($form, [
'activity_edit_form' => [ 'activity_edit_form' => [
'name' => 'Test 2', 'name' => 'An AcTiVitY Name',
'project' => '1',
] ]
]); ]);
$this->assertIsRedirect($client, $this->createUrl('/admin/activity/')); $this->assertIsRedirect($client, $this->createUrl('/admin/activity/'));
$client->followRedirect(); $client->followRedirect();
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
$this->request($client, '/admin/activity/2/edit');
$editForm = $client->getCrawler()->filter('form[name=activity_edit_form]')->form();
$this->assertEquals('An AcTiVitY Name', $editForm->get('activity_edit_form[name]')->getValue());
// make sure customer and project are pre-selected for none global activities
$this->assertEquals('1', $editForm->get('activity_edit_form[project]')->getValue());
$this->assertEquals('1', $editForm->get('activity_edit_form[customer]')->getValue());
} }
public function testCreateActionWithCreateMore() public function testCreateActionWithCreateMore()
@@ -101,6 +109,9 @@ class ActivityControllerTest extends ControllerBaseTest
$this->request($client, '/admin/activity/1/edit'); $this->request($client, '/admin/activity/1/edit');
$editForm = $client->getCrawler()->filter('form[name=activity_edit_form]')->form(); $editForm = $client->getCrawler()->filter('form[name=activity_edit_form]')->form();
$this->assertEquals('Test 2', $editForm->get('activity_edit_form[name]')->getValue()); $this->assertEquals('Test 2', $editForm->get('activity_edit_form[name]')->getValue());
// make sure no customer or project is pre-selected for global activities
$this->assertEquals('', $editForm->get('activity_edit_form[customer]')->getValue());
$this->assertEquals('', $editForm->get('activity_edit_form[project]')->getValue());
} }
public function testDeleteAction() public function testDeleteAction()

View File

@@ -33,7 +33,7 @@ class TimesheetControllerTest extends ControllerBaseTest
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
$result = $client->getCrawler()->filter('div.breadcrumb div.box-tools div.btn-group a.btn'); $result = $client->getCrawler()->filter('div.breadcrumb div.box-tools div.btn-group a.btn');
$this->assertEquals(3, count($result)); $this->assertEquals(4, count($result));
foreach ($result as $item) { foreach ($result as $item) {
$this->assertEquals('btn btn-default', $item->getAttribute('class')); $this->assertEquals('btn btn-default', $item->getAttribute('class'));
@@ -71,6 +71,44 @@ class TimesheetControllerTest extends ControllerBaseTest
// TODO more assertions // TODO more assertions
} }
public function testExportAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new TimesheetFixtures();
$fixture->setAmount(7);
$fixture->setUser($this->getUserByRole($em, User::ROLE_USER));
$fixture->setStartDate(new \DateTime('-10 days'));
$this->importFixture($em, $fixture);
$fixture = new TimesheetFixtures();
$fixture->setAmount(3);
$fixture->setUser($this->getUserByRole($em, User::ROLE_TEAMLEAD));
$fixture->setStartDate(new \DateTime('-10 days'));
$this->importFixture($em, $fixture);
$this->request($client, '/team/timesheet/');
$this->assertTrue($client->getResponse()->isSuccessful());
$form = $client->getCrawler()->filter('form.navbar-form')->form();
$form->getFormNode()->setAttribute('action', $this->createUrl('/team/timesheet/export'));
$client->submit($form, [
'state' => 1,
'pageSize' => 25,
'begin' => (new \DateTime('-10 days'))->format('Y-m-d'),
'end' => (new \DateTime())->format('Y-m-d'),
'customer' => null,
]);
$this->assertTrue($client->getResponse()->isSuccessful());
$node = $client->getCrawler()->filter('body');
$this->assertEquals('invoice_print', $node->getNode(0)->getAttribute('class'));
$result = $node->filter('section.invoice table.table tbody tr');
$this->assertEquals(10, count($result));
}
public function testCreateAction() public function testCreateAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);

View File

@@ -70,7 +70,7 @@ abstract class AbstractVoterTest extends TestCase
$invoice = ['view_invoice', 'create_invoice']; $invoice = ['view_invoice', 'create_invoice'];
$invoiceTemplate = ['view_invoice_template', 'create_invoice_template', 'edit_invoice_template', 'delete_invoice_template']; $invoiceTemplate = ['view_invoice_template', 'create_invoice_template', 'edit_invoice_template', 'delete_invoice_template'];
$timesheet = ['view_own_timesheet', 'start_own_timesheet', 'stop_own_timesheet', 'create_own_timesheet', 'edit_own_timesheet', 'export_own_timesheet', 'delete_own_timesheet']; $timesheet = ['view_own_timesheet', 'start_own_timesheet', 'stop_own_timesheet', 'create_own_timesheet', 'edit_own_timesheet', 'export_own_timesheet', 'delete_own_timesheet'];
$timesheetOthers = ['view_other_timesheet', 'start_other_timesheet', 'stop_other_timesheet', 'create_other_timesheet', 'edit_other_timesheet', 'delete_other_timesheet']; $timesheetOthers = ['view_other_timesheet', 'start_other_timesheet', 'stop_other_timesheet', 'create_other_timesheet', 'edit_other_timesheet', 'export_other_timesheet', 'delete_other_timesheet'];
$profile = ['view_own_profile', 'edit_own_profile', 'password_own_profile', 'preferences_own_profile', 'api-token_own_profile']; $profile = ['view_own_profile', 'edit_own_profile', 'password_own_profile', 'preferences_own_profile', 'api-token_own_profile'];
$profileOther = ['view_other_profile', 'edit_other_profile', 'delete_other_profile', 'password_other_profile', 'roles_other_profile', 'preferences_other_profile', 'api-token_other_profile']; $profileOther = ['view_other_profile', 'edit_other_profile', 'delete_other_profile', 'password_other_profile', 'roles_other_profile', 'preferences_other_profile', 'api-token_other_profile'];
$user = ['view_user', 'create_user', 'delete_user']; $user = ['view_user', 'create_user', 'delete_user'];

View File

@@ -62,6 +62,7 @@ class TimesheetVoterTest extends AbstractVoterTest
yield [$timeEntry[0], $timeEntry[1], 'stop', $result]; yield [$timeEntry[0], $timeEntry[1], 'stop', $result];
yield [$timeEntry[0], $timeEntry[1], 'edit', $result]; yield [$timeEntry[0], $timeEntry[1], 'edit', $result];
yield [$timeEntry[0], $timeEntry[1], 'delete', $result]; yield [$timeEntry[0], $timeEntry[1], 'delete', $result];
yield [$timeEntry[0], $timeEntry[1], 'export', $result];
} }
$result = VoterInterface::ACCESS_DENIED; $result = VoterInterface::ACCESS_DENIED;
@@ -73,6 +74,7 @@ class TimesheetVoterTest extends AbstractVoterTest
yield [$timeEntry[0], $timeEntry[1], 'stop', $result]; yield [$timeEntry[0], $timeEntry[1], 'stop', $result];
yield [$timeEntry[0], $timeEntry[1], 'edit', $result]; yield [$timeEntry[0], $timeEntry[1], 'edit', $result];
yield [$timeEntry[0], $timeEntry[1], 'delete', $result]; yield [$timeEntry[0], $timeEntry[1], 'delete', $result];
yield [$timeEntry[0], $timeEntry[1], 'export', $result];
} }
} }

View File

@@ -71,30 +71,31 @@ The permission-names were chosen to be self-explanatory. In the hope that it wor
| stop_own_timesheet | TIMESHEET | | - | | stop_own_timesheet | TIMESHEET | | - |
| create_own_timesheet | TIMESHEET | X | - | | create_own_timesheet | TIMESHEET | X | - |
| edit_own_timesheet | TIMESHEET | | - | | edit_own_timesheet | TIMESHEET | | - |
| export_own_timesheet | TIMESHEET | | - | | export_own_timesheet | TIMESHEET | | export your own timesheet in the timesheet panel |
| delete_own_timesheet | TIMESHEET | | - | | delete_own_timesheet | TIMESHEET | | - |
| view_other_timesheet | TIMESHEET_OTHER | | allows access to the complete timesheet view | | view_other_timesheet | TIMESHEET_OTHER | | allows access to the complete timesheet view |
| start_other_timesheet | TIMESHEET_OTHER | | - | | start_other_timesheet | TIMESHEET_OTHER | | - |
| stop_other_timesheet | TIMESHEET_OTHER | | - | | stop_other_timesheet | TIMESHEET_OTHER | | - |
| create_other_timesheet | TIMESHEET_OTHER | | - | | create_other_timesheet | TIMESHEET_OTHER | | - |
| edit_other_timesheet | TIMESHEET_OTHER | | - | | edit_other_timesheet | TIMESHEET_OTHER | | - |
| export_other_timesheet | TIMESHEET | | export timesheet in the timesheet admin panel |
| delete_other_timesheet | TIMESHEET_OTHER | | - | | delete_other_timesheet | TIMESHEET_OTHER | | - |
| view_rate_own_timesheet | RATE | | - | | view_rate_own_timesheet | RATE | | - |
| edit_rate_own_timesheet | RATE | | - | | edit_rate_own_timesheet | RATE | | - |
| view_rate_other_timesheet | RATE_OTHER | | - | | view_rate_other_timesheet | RATE_OTHER | | - |
| edit_rate_other_timesheet | RATE_OTHER | | - | | edit_rate_other_timesheet | RATE_OTHER | | - |
| view_own_profile | PROFILE | | Allows access to the own profile view. Without this permission, users cannot access any of their profile settings or passwords ... | | view_own_profile | PROFILE | | allows access to the own profile view - without this permission, users cannot access any of their profile settings or passwords ... |
| edit_own_profile | PROFILE | | - | | edit_own_profile | PROFILE | | grants access to edit the own profile |
| delete_own_profile | PROFILE | | - | | delete_own_profile | PROFILE | | grants access to delete the own profile |
| password_own_profile | PROFILE | | - | | password_own_profile | PROFILE | | grants access to change the own password |
| roles_own_profile | PROFILE | | - | | roles_own_profile | PROFILE | | SECURITY ALERT: grants access to the own roles |
| preferences_own_profile | PROFILE | | - | | preferences_own_profile | PROFILE | | grants access to the own preferences |
| api-token_own_profile | PROFILE | | - | | api-token_own_profile | PROFILE | | grants access to change the own API token |
| view_other_profile | PROFILE_OTHER | | - | | view_other_profile | PROFILE_OTHER | | - |
| edit_other_profile | PROFILE_OTHER | | - | | edit_other_profile | PROFILE_OTHER | | - |
| delete_other_profile | PROFILE_OTHER | | - | | delete_other_profile | PROFILE_OTHER | | - |
| password_other_profile | PROFILE_OTHER | | allows to change the password for another user | | password_other_profile | PROFILE_OTHER | | allows to change the password for another user |
| roles_other_profile | PROFILE_OTHER | | allows to change roles for other users | | roles_other_profile | PROFILE_OTHER | | SECURITY ALERT: allows to change roles for other users |
| preferences_other_profile | PROFILE_OTHER | | allows to change the preferences for another user | | preferences_other_profile | PROFILE_OTHER | | allows to change the preferences for another user |
| api-token_other_profile | PROFILE_OTHER | | allows to set the API login token for other users | | api-token_other_profile | PROFILE_OTHER | | allows to set the API login token for other users |
| hourly-rate_own_profile | - | | allows to edit the own user specific hourly rate |  | hourly-rate_own_profile | - | | allows to edit the own user specific hourly rate |