API: changed date-format, camelCase instead of snake_case, null values, update and create for customer and project (#718)

This commit is contained in:
Kevin Papst
2019-04-24 18:13:33 +02:00
committed by GitHub
parent 215d4fc8bf
commit 460391136f
61 changed files with 2304 additions and 505 deletions

View File

@@ -14,7 +14,6 @@ services:
matrix: matrix:
fast_finish: true fast_finish: true
include: include:
- php: 7.1
- php: 7.2 - php: 7.2
- php: 7.3 - php: 7.3

View File

@@ -22,7 +22,7 @@ Kimai is a [multi-language application](https://www.kimai.org/documentation/tran
### Requirements ### Requirements
- PHP 7.1.3 or higher (test your system compatibility with the [requirements-checker](http://symfony.com/doc/current/reference/requirements.html)) - PHP 7.2 or higher (test your system compatibility with the [requirements-checker](http://symfony.com/doc/current/reference/requirements.html))
- The PHP extensions [xml](http://php.net/manual/en/book.xml.php), [mbstring](http://php.net/manual/en/book.mbstring.php), [gd](http://php.net/manual/en/book.image.php), [intl](https://php.net/manual/en/book.intl.php), [zip](https://php.net/manual/en/book.zip.php) and [PDO](https://php.net/manual/en/book.pdo.php) with either [pdo_sqlite](https://php.net/manual/en/ref.pdo-sqlite.php) or [pdo_mysql](https://php.net/manual/en/ref.pdo-mysql.php) enabled - The PHP extensions [xml](http://php.net/manual/en/book.xml.php), [mbstring](http://php.net/manual/en/book.mbstring.php), [gd](http://php.net/manual/en/book.image.php), [intl](https://php.net/manual/en/book.intl.php), [zip](https://php.net/manual/en/book.zip.php) and [PDO](https://php.net/manual/en/book.pdo.php) with either [pdo_sqlite](https://php.net/manual/en/ref.pdo-sqlite.php) or [pdo_mysql](https://php.net/manual/en/ref.pdo-mysql.php) enabled
- If you use MariaDB, make sure its at least v10.2.7 (see [FAQ](https://www.kimai.org/documentation/faq.html)) - If you use MariaDB, make sure its at least v10.2.7 (see [FAQ](https://www.kimai.org/documentation/faq.html))
- A modern browser, Kimai v2 might be broken on old browsers like IE 10 - A modern browser, Kimai v2 might be broken on old browsers like IE 10

View File

@@ -24,14 +24,18 @@ Follow the normal update and database migration process (see above).
Remember to execute the necessary timezone conversion script, if you haven't updated to 0.8 before (see below)! Remember to execute the necessary timezone conversion script, if you haven't updated to 0.8 before (see below)!
**BC BREAKS** ### BC BREAKS
- in an ongoing effort to simplify future installation and upgrade processes the `.env` variable `DATABASE_PREFIX` was removed.
The table prefix is now hardcoded to `kimai2_`. If you used another prefix, you have to rename your tables manually This release contains some BC breaks, which were necessary before 1.0 will be released: "now or never" ;-) sorry for the troubles!
before starting the update process. And delete the row `DATABASE_PREFIX` from your `.env` file.
- API: DateTime objects will be returned including timezone identifier (previously 2019-03-02 14:23 - now 2019-03-02T14:23:00+00:00) - **Kimai requires PHP 7.2 now => [PHP 7.1 expired 4 month ago](https://www.php.net/supported-versions.php)**
- The `.env` variable `DATABASE_PREFIX` was removed and the table prefix is now hardcoded to `kimai2_`. If you used another prefix,
you have to rename your tables manually before starting the update process. You can delete the row `DATABASE_PREFIX` from your `.env` file.
- API: Format for DateTime objects changed, now including timezone identifier (previously 2019-03-02 14:23 - now 2019-03-02T14:23:00+00:00), see [#718](https://github.com/kevinpapst/kimai2/pull/718)
- API: changed from snake_case to camelCase (hourlyRate vs hourly_rate / fixedRate vs fixed_rate / orderNumber vs order_number / i18n config)
- Plugin mechanism changed: existing Plugins have to be deleted or updated - Plugin mechanism changed: existing Plugins have to be deleted or updated
**Apply necessary changes to your `local.yaml`:** ### Apply necessary changes to your `local.yaml`:
New permissions are available: New permissions are available:
- `system_configuration` - for accessing the new system configuration screen - `system_configuration` - for accessing the new system configuration screen
@@ -49,7 +53,7 @@ After you followed the normal update and database migration process (see above),
- Read this [pull request](https://github.com/kevinpapst/kimai2/pull/372) BEFORE you follow the instructions to convert the - Read this [pull request](https://github.com/kevinpapst/kimai2/pull/372) BEFORE you follow the instructions to convert the
timezones in your existing time records with `bin/console kimai:convert-timezone`. Without that, you will end up with wrong times in your database. timezones in your existing time records with `bin/console kimai:convert-timezone`. Without that, you will end up with wrong times in your database.
**Apply necessary changes to your `local.yaml`:** ### Apply necessary changes to your `local.yaml`:
- A new boolean setting `kimai.timesheet.rules.allow_future_times` was introduced - A new boolean setting `kimai.timesheet.rules.allow_future_times` was introduced
- New permissions are available: - New permissions are available:

View File

@@ -10,7 +10,7 @@
} }
], ],
"require": { "require": {
"php": "^7.1.3", "php": "^7.2",
"ext-gd": "*", "ext-gd": "*",
"ext-intl": "*", "ext-intl": "*",
"ext-mbstring": "*", "ext-mbstring": "*",
@@ -23,7 +23,9 @@
"friendsofsymfony/user-bundle": "~2.0", "friendsofsymfony/user-bundle": "~2.0",
"fzaninotto/faker": "^1.8", "fzaninotto/faker": "^1.8",
"gedmo/doctrine-extensions": "^2.4", "gedmo/doctrine-extensions": "^2.4",
"jms/serializer-bundle": "^2.4", "jms/metadata": "^2.0",
"jms/serializer": "^2.3",
"jms/serializer-bundle": "^3.2",
"kevinpapst/adminlte-bundle": "~2.1", "kevinpapst/adminlte-bundle": "~2.1",
"kimai/kimai2-composer": "^0.1", "kimai/kimai2-composer": "^0.1",
"mpdf/mpdf": "^7.1", "mpdf/mpdf": "^7.1",

1164
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
jms_serializer: jms_serializer:
visitors: visitors:
json: json_serialization:
options: options:
- JSON_PRETTY_PRINT - JSON_PRETTY_PRINT
- JSON_UNESCAPED_SLASHES - JSON_UNESCAPED_SLASHES

View File

@@ -29,10 +29,10 @@ fos_rest:
# view_handler: fos_rest.view_handler.default # view_handler: fos_rest.view_handler.default
# inflector: fos_rest.inflector.doctrine # inflector: fos_rest.inflector.doctrine
# validator: validator # validator: validator
# serializer: serializer:
# version: null # version: null
# groups: [] # groups: []
# serialize_null: false serialize_null: true
view: view:
default_engine: twig default_engine: twig
# force_redirects: # force_redirects:
@@ -75,7 +75,7 @@ fos_rest:
# decoders: # decoders:
# name: ~ # name: ~
array_normalizer: array_normalizer:
service: null service: fos_rest.normalizer.camel_keys
forms: true forms: true
format_listener: format_listener:
enabled: true enabled: true

View File

@@ -1,6 +1,9 @@
jms_serializer: jms_serializer:
handlers:
datetime:
default_format: 'Y-m-d\TH:i:sO' # DATE_ISO8601
visitors: visitors:
xml: xml_serialization:
format_output: '%kernel.debug%' format_output: '%kernel.debug%'
metadata: metadata:
directories: directories:
@@ -10,3 +13,5 @@ jms_serializer:
App: App:
namespace_prefix: "App" namespace_prefix: "App"
path: "%kernel.root_dir%/../config/serializer/App" path: "%kernel.root_dir%/../config/serializer/App"
property_naming:
id: 'jms_serializer.identical_property_naming_strategy'

View File

@@ -2,13 +2,20 @@ nelmio_api_doc:
models: models:
use_jms: true use_jms: true
names: names:
- { alias: CustomerEditForm, type: App\Form\CustomerEditForm, groups: [Default, Entity, Customer] }
- { alias: CustomerEntity, type: App\Entity\Customer, groups: [Default, Entity, Customer] } - { alias: CustomerEntity, type: App\Entity\Customer, groups: [Default, Entity, Customer] }
- { alias: CustomerCollection, type: App\Entity\Customer, groups: [Default, Collection, Customer] }
- { alias: ProjectEditForm, type: App\Form\ProjectEditForm, groups: [Default, Entity, Project] }
- { alias: ProjectEntity, type: App\Entity\Project, groups: [Default, Entity, Project] } - { alias: ProjectEntity, type: App\Entity\Project, groups: [Default, Entity, Project] }
- { alias: ActivityEntity, type: App\Entity\Activity, groups: [Default, Entity, Activity] } - { alias: ProjectCollection, type: App\Entity\Project, groups: [Default, Collection, Project] }
- { alias: ActivityEditForm, type: App\Form\ActivityEditForm, groups: [Default, Entity, Activity] } - { alias: ActivityEditForm, type: App\Form\ActivityEditForm, groups: [Default, Entity, Activity] }
- { alias: ActivityEntity, type: App\Entity\Activity, groups: [Default, Entity, Activity] }
- { alias: ActivityCollection, type: App\Entity\Activity, groups: [Default, Collection, Activity] }
- { alias: TimesheetEditForm, type: App\Form\TimesheetEditForm, groups: [Default, Entity, Timesheet] } - { alias: TimesheetEditForm, type: App\Form\TimesheetEditForm, groups: [Default, Entity, Timesheet] }
- { alias: TimesheetEntity, type: App\Entity\Timesheet, groups: [Default, Entity, Timesheet] } - { alias: TimesheetEntity, type: App\Entity\Timesheet, groups: [Default, Entity, Timesheet] }
- { alias: TimesheetCollection, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet] }
- { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User] } - { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User] }
- { alias: UserCollection, type: App\Entity\User, groups: [Default, Collection, User] }
- { alias: I18nConfig, type: App\API\Model\I18n, groups: [Default] } - { alias: I18nConfig, type: App\API\Model\I18n, groups: [Default] }
areas: areas:
path_patterns: path_patterns:
@@ -18,8 +25,11 @@ nelmio_api_doc:
schemes: [http, https] schemes: [http, https]
info: info:
title: Kimai 2 - API Docs title: Kimai 2 - API Docs
description: REST API for the Kimai 2 time-tracking software. It's rather limited by now. If you need other methods, please let me know at GitHub! description: |
version: 0.2 REST API for the Kimai 2 time-tracking software. It is not yet considered stable and BC breaks might happen, even though I try to avoid them as much as possible.
- Collections return less data than explicit entity calls
- DateTime formats are explained in detail at https://www.kimai.org/documentation/rest-api.html
version: 0.3
# parameters: # parameters:
# hostname: # hostname:
# name: hostname # name: hostname

View File

@@ -1,6 +1,6 @@
jms_serializer: jms_serializer:
visitors: visitors:
json: json_serialization:
options: options:
- JSON_UNESCAPED_SLASHES - JSON_UNESCAPED_SLASHES
- JSON_PRESERVE_ZERO_FRACTION - JSON_PRESERVE_ZERO_FRACTION

View File

@@ -0,0 +1,25 @@
App\API\Model\Version:
exclusion_policy: All
custom_accessor_order: [version, candidate, semver, name, copyright]
properties:
version:
include: true
type: string
example: 0.9
groups: [Default]
candidate:
include: true
type: string
groups: [Default]
semver:
include: true
type: string
groups: [Default]
name:
include: true
type: string
groups: [Default]
copyright:
include: true
type: string
groups: [Default]

View File

@@ -22,10 +22,10 @@ App\Entity\Project:
groups: [Entity] groups: [Entity]
fixedRate: fixedRate:
include: true include: true
groups: [Entity] groups: [Default]
hourlyRate: hourlyRate:
include: true include: true
groups: [Entity] groups: [Default]
customer: customer:
include: false include: false
exclude: true exclude: true

View File

@@ -6,11 +6,9 @@ App\Entity\Timesheet:
include: true include: true
groups: [Default] groups: [Default]
begin: begin:
include: true exclude: true
groups: [Default]
end: end:
include: true exclude: true
groups: [Default]
duration: duration:
include: true include: true
groups: [Default] groups: [Default]
@@ -30,18 +28,22 @@ App\Entity\Timesheet:
include: true include: true
groups: [Entity] groups: [Entity]
activity: activity:
include: false
exclude: true exclude: true
groups: [Default]
project: project:
include: false
exclude: true exclude: true
groups: [Default]
user: user:
include: false
exclude: true exclude: true
groups: [Default]
virtual_properties: virtual_properties:
getBegin:
serialized_name: begin
exp: "object.getBegin() === null ? null : object.getBegin()"
type: DateTime
groups: [Default]
getEnd:
serialized_name: end
exp: "object.getEnd() === null ? null : object.getEnd()"
type: DateTime
groups: [Default]
getActivity: getActivity:
serialized_name: activity serialized_name: activity
exp: "object.getActivity() === null ? null : object.getActivity().getId()" exp: "object.getActivity() === null ? null : object.getActivity().getId()"

View File

@@ -1,5 +1,6 @@
App\Entity\User: App\Entity\User:
exclusion_policy: All exclusion_policy: All
custom_accessor_order: [id, alias, title, avatar, language, timezone]
properties: properties:
id: id:
include: true include: true
@@ -13,3 +14,14 @@ App\Entity\User:
avatar: avatar:
include: true include: true
groups: [Entity] groups: [Entity]
virtual_properties:
getLanguage:
serialized_name: language
exp: "object.getPreferenceValue('language') === null ? null : object.getPreferenceValue('language')"
type: string
groups: [Entity]
getTimezone:
serialized_name: timezone
exp: "object.getPreferenceValue('timezone') === null ? null : object.getPreferenceValue('timezone')"
type: string
groups: [Entity]

View File

@@ -1,16 +1,18 @@
FOS\UserBundle\Model\User: FOS\UserBundle\Model\User:
exclusion_policy: NONE exclusion_policy: All
properties: properties:
username:
include: true
groups: [Default]
enabled: enabled:
include: true include: true
groups: [Default] groups: [Default]
groups:
include: true
groups: [Entity]
roles: roles:
type: array<string> type: array<string>
include: true include: true
groups: [Entity] groups: [Entity]
groups:
exclude: true
email: email:
exclude: true exclude: true
emailCanonical: emailCanonical:

View File

@@ -24,6 +24,7 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG; use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/** /**
* @RouteResource("Activity") * @RouteResource("Activity")
@@ -53,20 +54,22 @@ class ActivityController extends BaseApiController
} }
/** /**
* Returns a collection of activities
*
* @SWG\Response( * @SWG\Response(
* response=200, * response=200,
* description="Returns the collection of all existing activities", * description="Returns a collection of activity entities",
* @SWG\Schema( * @SWG\Schema(
* type="array", * type="array",
* @SWG\Items(ref="#/definitions/ActivityEntity") * @SWG\Items(ref="#/definitions/ActivityCollection")
* ) * )
* ) * )
* @Rest\QueryParam(name="project", requirements="\d+", strict=true, nullable=true, description="Project ID to filter activities. If none is provided, only global activities will be returned.") * @Rest\QueryParam(name="project", requirements="\d+", strict=true, nullable=true, description="Project ID to filter activities. If none is provided, all activities will be returned.")
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter activities (1=visible, 2=hidden, 3=both)") * @Rest\QueryParam(name="visible", requirements="1|2|3", strict=true, nullable=true, description="Visibility status to filter activities. Allowed values: 1=visible, 2=hidden, 3=all (default: 1)")
* @Rest\QueryParam(name="globals", requirements="true", strict=true, nullable=true, description="Pass 'true' as string to fetch only global activities") * @Rest\QueryParam(name="globals", requirements="true", strict=true, nullable=true, description="Use if you want to fetch only global activities. Allowed values: true (default: false)")
* @Rest\QueryParam(name="globalsFirst", requirements="false", strict=true, nullable=true, description="Pass 'false' as string if you don't want the global activities to be listed first") * @Rest\QueryParam(name="globalsFirst", requirements="false", strict=true, nullable=true, description="Use if you don't want global activities to be listed first. Allowed values: false (default: true)")
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order (allowed values: 'ASC', 'DESC')") * @Rest\QueryParam(name="orderBy", requirements="id|name|project", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, name, project (default: name)")
* @Rest\QueryParam(name="orderBy", requirements="id|name|project", strict=true, nullable=true, description="The field by which results will be ordered (allowed values: 'id', 'name', 'project')") * @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)")
* *
* @return Response * @return Response
*/ */
@@ -110,11 +113,20 @@ class ActivityController extends BaseApiController
} }
/** /**
* Returns one activity
*
* @SWG\Response( * @SWG\Response(
* response=200, * response=200,
* description="Returns one activity entity", * description="Returns one activity entity",
* @SWG\Schema(ref="#/definitions/ActivityEntity"), * @SWG\Schema(ref="#/definitions/ActivityEntity"),
* ) * )
* @SWG\Parameter(
* name="id",
* in="path",
* type="integer",
* description="Activity ID to fetch",
* required=true,
* )
* *
* @param int $id * @param int $id
* @return Response * @return Response
@@ -122,9 +134,11 @@ class ActivityController extends BaseApiController
public function getAction($id) public function getAction($id)
{ {
$data = $this->repository->find($id); $data = $this->repository->find($id);
if (null === $data) { if (null === $data) {
throw new NotFoundException(); throw new NotFoundException();
} }
$view = new View($data, 200); $view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Activity']); $view->getContext()->setGroups(['Default', 'Entity', 'Activity']);
@@ -132,11 +146,13 @@ class ActivityController extends BaseApiController
} }
/** /**
* Creates a new activity
*
* @SWG\Post( * @SWG\Post(
* description="Creates a new activity entry and returns it afterwards", * description="Creates a new activity and returns it afterwards",
* @SWG\Response( * @SWG\Response(
* response=200, * response=200,
* description="Returns the new created activity entry", * description="Returns the new created activity",
* @SWG\Schema(ref="#/definitions/ActivityEntity"), * @SWG\Schema(ref="#/definitions/ActivityEntity"),
* ) * )
* ) * )
@@ -156,7 +172,7 @@ class ActivityController extends BaseApiController
public function postAction(Request $request) public function postAction(Request $request)
{ {
if (!$this->isGranted('create_activity')) { if (!$this->isGranted('create_activity')) {
throw $this->createAccessDeniedException('User cannot create activities'); throw new AccessDeniedHttpException('User cannot create activities');
} }
$activity = new Activity(); $activity = new Activity();
@@ -168,10 +184,6 @@ class ActivityController extends BaseApiController
$form->submit($request->request->all()); $form->submit($request->request->all());
if ($form->isValid()) { if ($form->isValid()) {
if (null !== $activity->getId()) {
return new Response('This method does not support updates', Response::HTTP_BAD_REQUEST);
}
$entityManager = $this->getDoctrine()->getManager(); $entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($activity); $entityManager->persist($activity);
$entityManager->flush(); $entityManager->flush();
@@ -189,11 +201,13 @@ class ActivityController extends BaseApiController
} }
/** /**
* Update an existing activity
*
* @SWG\Patch( * @SWG\Patch(
* description="Update an existing activity entry, you can pass all or just a subset of all attributes", * description="Update an existing activity, you can pass all or just a subset of all attributes",
* @SWG\Response( * @SWG\Response(
* response=200, * response=200,
* description="Returns the updated activity entry", * description="Returns the updated activity",
* @SWG\Schema(ref="#/definitions/ActivityEntity") * @SWG\Schema(ref="#/definitions/ActivityEntity")
* ) * )
* ) * )
@@ -203,6 +217,13 @@ class ActivityController extends BaseApiController
* required=true, * required=true,
* @SWG\Schema(ref="#/definitions/ActivityEditForm") * @SWG\Schema(ref="#/definitions/ActivityEditForm")
* ) * )
* @SWG\Parameter(
* name="id",
* in="path",
* type="integer",
* description="Activity ID to update",
* required=true,
* )
* *
* @param Request $request * @param Request $request
* @param string $id * @param string $id
@@ -212,8 +233,12 @@ class ActivityController extends BaseApiController
{ {
$activity = $this->repository->find($id); $activity = $this->repository->find($id);
if (null === $activity) {
throw new NotFoundException();
}
if (!$this->isGranted('edit', $activity)) { if (!$this->isGranted('edit', $activity)) {
throw $this->createAccessDeniedException('User cannot update activity'); throw new AccessDeniedHttpException('User cannot update activity');
} }
$form = $this->createForm(ActivityEditForm::class, $activity, [ $form = $this->createForm(ActivityEditForm::class, $activity, [

View File

@@ -12,7 +12,10 @@ declare(strict_types=1);
namespace App\API; namespace App\API;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
abstract class BaseApiController extends AbstractController abstract class BaseApiController extends AbstractController
{ {
public const DATE_FORMAT = DateTimeType::HTML5_FORMAT;
public const DATE_FORMAT_PHP = 'Y-m-d\TH:m:s';
} }

View File

@@ -17,8 +17,12 @@ use App\Entity\User;
use FOS\RestBundle\Controller\Annotations as Rest; use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\View\View; use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG; use Swagger\Annotations as SWG;
/**
* @Security("is_granted('ROLE_USER')")
*/
class ConfigurationController extends BaseApiController class ConfigurationController extends BaseApiController
{ {
/** /**
@@ -41,6 +45,8 @@ class ConfigurationController extends BaseApiController
} }
/** /**
* Returns the user specific locale configuration
*
* @SWG\Response( * @SWG\Response(
* response=200, * response=200,
* description="Returns the locale specific configurations for this user", * description="Returns the locale specific configurations for this user",

View File

@@ -11,6 +11,8 @@ declare(strict_types=1);
namespace App\API; namespace App\API;
use App\Entity\Customer;
use App\Form\CustomerEditForm;
use App\Repository\CustomerRepository; use App\Repository\CustomerRepository;
use App\Repository\Query\CustomerQuery; use App\Repository\Query\CustomerQuery;
use FOS\RestBundle\Controller\Annotations as Rest; use FOS\RestBundle\Controller\Annotations as Rest;
@@ -20,7 +22,9 @@ use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG; use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/** /**
* @RouteResource("Customer") * @RouteResource("Customer")
@@ -50,17 +54,19 @@ class CustomerController extends BaseApiController
} }
/** /**
* Returns a collection of customers
*
* @SWG\Response( * @SWG\Response(
* response=200, * response=200,
* description="Returns the collection of all existing customer", * description="Returns a collection of customer entities",
* @SWG\Schema( * @SWG\Schema(
* type="array", * type="array",
* @SWG\Items(ref="#/definitions/CustomerEntity") * @SWG\Items(ref="#/definitions/CustomerCollection")
* ) * )
* ) * )
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter activities (1=visible, 2=hidden, 3=both)") * @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter activities (1=visible, 2=hidden, 3=both)")
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order (allowed values: 'ASC', 'DESC')") * @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)")
* @Rest\QueryParam(name="orderBy", requirements="id|name", strict=true, nullable=true, description="The field by which results will be ordered (allowed values: 'id', 'name')") * @Rest\QueryParam(name="orderBy", requirements="id|name", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, name (default: name)")
* *
* @return Response * @return Response
*/ */
@@ -92,6 +98,8 @@ class CustomerController extends BaseApiController
} }
/** /**
* Returns one customer
*
* @SWG\Response( * @SWG\Response(
* response=200, * response=200,
* description="Returns one customer entity", * description="Returns one customer entity",
@@ -104,12 +112,134 @@ class CustomerController extends BaseApiController
public function getAction($id) public function getAction($id)
{ {
$data = $this->repository->find($id); $data = $this->repository->find($id);
if (null === $data) { if (null === $data) {
throw new NotFoundException(); throw new NotFoundException();
} }
$view = new View($data, 200); $view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Customer']); $view->getContext()->setGroups(['Default', 'Entity', 'Customer']);
return $this->viewHandler->handle($view); return $this->viewHandler->handle($view);
} }
/**
* Creates a new customer
*
* @SWG\Post(
* description="Creates a new customer and returns it afterwards",
* @SWG\Response(
* response=200,
* description="Returns the new created customer",
* @SWG\Schema(ref="#/definitions/CustomerEntity"),
* )
* )
* @SWG\Parameter(
* name="body",
* in="body",
* required=true,
* @SWG\Schema(ref="#/definitions/CustomerEditForm")
* )
*
* @param Request $request
* @return Response
* @throws \App\Repository\RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function postAction(Request $request)
{
if (!$this->isGranted('create_customer')) {
throw new AccessDeniedHttpException('User cannot create customers');
}
$customer = new Customer();
$form = $this->createForm(CustomerEditForm::class, $customer, [
'csrf_protection' => false,
]);
$form->submit($request->request->all());
if ($form->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($customer);
$entityManager->flush();
$view = new View($customer, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Customer']);
return $this->viewHandler->handle($view);
}
$view = new View($form);
$view->getContext()->setGroups(['Default', 'Entity', 'Customer']);
return $this->viewHandler->handle($view);
}
/**
* Update an existing customer
*
* @SWG\Patch(
* description="Update an existing customer, you can pass all or just a subset of all attributes",
* @SWG\Response(
* response=200,
* description="Returns the updated customer",
* @SWG\Schema(ref="#/definitions/CustomerEntity")
* )
* )
* @SWG\Parameter(
* name="body",
* in="body",
* required=true,
* @SWG\Schema(ref="#/definitions/CustomerEditForm")
* )
* @SWG\Parameter(
* name="id",
* in="path",
* type="integer",
* description="Customer ID to update",
* required=true,
* )
*
* @param Request $request
* @param string $id
* @return Response
*/
public function patchAction(Request $request, string $id)
{
$customer = $this->repository->find($id);
if (null === $customer) {
throw new NotFoundException();
}
if (!$this->isGranted('edit', $customer)) {
throw new AccessDeniedHttpException('User cannot update customer');
}
$form = $this->createForm(CustomerEditForm::class, $customer, [
'csrf_protection' => false,
]);
$form->setData($customer);
$form->submit($request->request->all(), false);
if (false === $form->isValid()) {
$view = new View($form, Response::HTTP_OK);
$view->getContext()->setGroups(['Default', 'Entity', 'Customer']);
return $this->viewHandler->handle($view);
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($customer);
$entityManager->flush();
$view = new View($customer, Response::HTTP_OK);
$view->getContext()->setGroups(['Default', 'Entity', 'Customer']);
return $this->viewHandler->handle($view);
}
} }

View File

@@ -14,13 +14,13 @@ namespace App\API\Model;
class I18n class I18n
{ {
/** /**
* Format used for 'begin' and 'end' in TimesheetEditForm: POST, PATCH * Format used for 'begin' and 'end'
* *
* @var string * @var string
*/ */
protected $formDateTime = ''; protected $formDateTime = '';
/** /**
* Format used for Timesheet queries in: GET * Format used for toolbar queries
* *
* @var string * @var string
*/ */

38
src/API/Model/Version.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\API\Model;
use App\Constants;
class Version
{
/**
* @var string
*/
protected $version = Constants::VERSION;
/**
* @var string
*/
protected $candidate = Constants::STATUS;
/**
* @var string
*/
protected $semver = Constants::VERSION . '-' . Constants::STATUS;
/**
* @var string
*/
protected $name = Constants::NAME;
/**
* @var string
*/
protected $copyright = Constants::SOFTWARE . ' - ' . Constants::VERSION . ' ' . Constants::STATUS . ' (' . Constants::NAME . ') by Kevin Papst and contributors.';
}

View File

@@ -11,6 +11,8 @@ declare(strict_types=1);
namespace App\API; namespace App\API;
use App\Entity\Project;
use App\Form\ProjectEditForm;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\Query\ProjectQuery; use App\Repository\Query\ProjectQuery;
use FOS\RestBundle\Controller\Annotations as Rest; use FOS\RestBundle\Controller\Annotations as Rest;
@@ -20,7 +22,9 @@ use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG; use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/** /**
* @RouteResource("Project") * @RouteResource("Project")
@@ -50,18 +54,20 @@ class ProjectController extends BaseApiController
} }
/** /**
* Returns a collection of projects
*
* @SWG\Response( * @SWG\Response(
* response=200, * response=200,
* description="Returns the collection of all existing projects", * description="Returns a collection of project entities",
* @SWG\Schema( * @SWG\Schema(
* type="array", * type="array",
* @SWG\Items(ref="#/definitions/ProjectEntity") * @SWG\Items(ref="#/definitions/ProjectCollection")
* ) * )
* ) * )
* @Rest\QueryParam(name="customer", requirements="\d+", strict=true, nullable=true, description="Customer ID to filter projects") * @Rest\QueryParam(name="customer", requirements="\d+", strict=true, nullable=true, description="Customer ID to filter projects")
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter projects (1=visible, 2=hidden, 3=both)") * @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter projects (1=visible, 2=hidden, 3=both)")
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order (allowed values: 'ASC', 'DESC')") * @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)")
* @Rest\QueryParam(name="orderBy", requirements="id|name", strict=true, nullable=true, description="The field by which results will be ordered (allowed values: 'id', 'name')") * @Rest\QueryParam(name="orderBy", requirements="id|name|customer", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, name, customer (default: name)")
* *
* @param ParamFetcherInterface $paramFetcher * @param ParamFetcherInterface $paramFetcher
* @return Response * @return Response
@@ -98,6 +104,8 @@ class ProjectController extends BaseApiController
} }
/** /**
* Returns one project
*
* @SWG\Response( * @SWG\Response(
* response=200, * response=200,
* description="Returns one project entity", * description="Returns one project entity",
@@ -118,4 +126,124 @@ class ProjectController extends BaseApiController
return $this->viewHandler->handle($view); return $this->viewHandler->handle($view);
} }
/**
* Creates a new project
*
* @SWG\Post(
* description="Creates a new project and returns it afterwards",
* @SWG\Response(
* response=200,
* description="Returns the new created project",
* @SWG\Schema(ref="#/definitions/ProjectEntity"),
* )
* )
* @SWG\Parameter(
* name="body",
* in="body",
* required=true,
* @SWG\Schema(ref="#/definitions/ProjectEditForm")
* )
*
* @param Request $request
* @return Response
* @throws \App\Repository\RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function postAction(Request $request)
{
if (!$this->isGranted('create_project')) {
throw new AccessDeniedHttpException('User cannot create projects');
}
$project = new Project();
$form = $this->createForm(ProjectEditForm::class, $project, [
'csrf_protection' => false,
]);
$form->submit($request->request->all());
if ($form->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($project);
$entityManager->flush();
$view = new View($project, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Project']);
return $this->viewHandler->handle($view);
}
$view = new View($form);
$view->getContext()->setGroups(['Default', 'Entity', 'Project']);
return $this->viewHandler->handle($view);
}
/**
* Update an existing project
*
* @SWG\Patch(
* description="Update an existing project, you can pass all or just a subset of all attributes",
* @SWG\Response(
* response=200,
* description="Returns the updated project",
* @SWG\Schema(ref="#/definitions/ProjectEntity")
* )
* )
* @SWG\Parameter(
* name="body",
* in="body",
* required=true,
* @SWG\Schema(ref="#/definitions/ProjectEditForm")
* )
* @SWG\Parameter(
* name="id",
* in="path",
* type="integer",
* description="Project ID to update",
* required=true,
* )
*
* @param Request $request
* @param string $id
* @return Response
*/
public function patchAction(Request $request, string $id)
{
$project = $this->repository->find($id);
if (null === $project) {
throw new NotFoundException();
}
if (!$this->isGranted('edit', $project)) {
throw new AccessDeniedHttpException('User cannot update project');
}
$form = $this->createForm(ProjectEditForm::class, $project, [
'csrf_protection' => false,
]);
$form->setData($project);
$form->submit($request->request->all(), false);
if (false === $form->isValid()) {
$view = new View($form, Response::HTTP_OK);
$view->getContext()->setGroups(['Default', 'Entity', 'Project']);
return $this->viewHandler->handle($view);
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($project);
$entityManager->flush();
$view = new View($project, Response::HTTP_OK);
$view->getContext()->setGroups(['Default', 'Entity', 'Project']);
return $this->viewHandler->handle($view);
}
} }

View File

@@ -11,13 +11,14 @@ declare(strict_types=1);
namespace App\API; namespace App\API;
use App\Constants; use App\API\Model\Version;
use FOS\RestBundle\Controller\Annotations as Rest; use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\View\View; use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model;
use Swagger\Annotations as SWG; use Swagger\Annotations as SWG;
class HealthcheckController extends BaseApiController class StatusController extends BaseApiController
{ {
/** /**
* @var ViewHandlerInterface * @var ViewHandlerInterface
@@ -33,6 +34,8 @@ class HealthcheckController extends BaseApiController
} }
/** /**
* A testing route for the API
*
* @SWG\Response( * @SWG\Response(
* response=200, * response=200,
* description="A simple route that returns a 'pong', which you can use for testing the API", * description="A simple route that returns a 'pong', which you can use for testing the API",
@@ -49,23 +52,18 @@ class HealthcheckController extends BaseApiController
} }
/** /**
* Returns information about the Kimai release
*
* @SWG\Response( * @SWG\Response(
* response=200, * response=200,
* description="Returns version information about the current release", * description="Returns version information about the current release",
* @SWG\Schema(ref=@Model(type=Version::class))
* ) * )
* *
* @Rest\Get(path="/version") * @Rest\Get(path="/version")
*/ */
public function versionAction() public function versionAction()
{ {
$version = [ return $this->viewHandler->handle(new View(new Version(), 200));
'version' => Constants::VERSION,
'candidate' => Constants::STATUS,
'semver' => Constants::VERSION . '-' . Constants::STATUS,
'name' => Constants::NAME,
'copyright' => 'Kimai 2 - ' . Constants::VERSION . ' ' . Constants::STATUS . ' (' . Constants::NAME . ') by Kevin Papst and contributors.',
];
return $this->viewHandler->handle(new View($version, 200));
} }
} }

View File

@@ -27,10 +27,13 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG; use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Validator\Constraints; use Symfony\Component\Validator\Constraints;
/** /**
* @RouteResource("Timesheet") * @RouteResource("Timesheet")
*
* @Security("is_granted('ROLE_USER')")
*/ */
class TimesheetController extends BaseApiController class TimesheetController extends BaseApiController
{ {
@@ -67,26 +70,29 @@ class TimesheetController extends BaseApiController
} }
/** /**
* Returns a collection of timesheet records
*
* @SWG\Response( * @SWG\Response(
* response=200, * response=200,
* description="Returns the collection of all existing timesheets for the user", * description="Returns a collection of timesheets records. Be aware that the datetime fields are given in the users local time including the timezone offset via ISO 8601.",
* @SWG\Schema( * @SWG\Schema(
* type="array", * type="array",
* @SWG\Items(ref="#/definitions/TimesheetEntity") * @SWG\Items(ref="#/definitions/TimesheetCollection")
* ) * )
* ) * )
* *
* @Rest\QueryParam(name="user", requirements="\d+|all", strict=true, nullable=true, description="User ID to filter timesheets (needs permission 'view_other_timesheet', pass 'all' to fetch data for all user)") * @Rest\QueryParam(name="user", requirements="\d+|all", strict=true, nullable=true, description="User ID to filter timesheets. Needs permission 'view_other_timesheet', pass 'all' to fetch data for all user (default: current user)")
* @Rest\QueryParam(name="customer", requirements="\d+", strict=true, nullable=true, description="Customer ID to filter timesheets") * @Rest\QueryParam(name="customer", requirements="\d+", strict=true, nullable=true, description="Customer ID to filter timesheets")
* @Rest\QueryParam(name="project", requirements="\d+", strict=true, nullable=true, description="Project ID to filter timesheets") * @Rest\QueryParam(name="project", requirements="\d+", strict=true, nullable=true, description="Project ID to filter timesheets")
* @Rest\QueryParam(name="activity", requirements="\d+", strict=true, nullable=true, description="Activity ID to filter timesheets") * @Rest\QueryParam(name="activity", requirements="\d+", strict=true, nullable=true, description="Activity ID to filter timesheets")
* @Rest\QueryParam(name="page", requirements="\d+", strict=true, nullable=true, description="The page to display, renders a 404 if not found (default: 1)") * @Rest\QueryParam(name="page", requirements="\d+", strict=true, nullable=true, description="The page to display, renders a 404 if not found (default: 1)")
* @Rest\QueryParam(name="size", requirements="\d+", strict=true, nullable=true, description="The amount of entries for each page (default: 25)") * @Rest\QueryParam(name="size", requirements="\d+", strict=true, nullable=true, description="The amount of entries for each page (default: 25)")
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order (allowed values: 'ASC', 'DESC')") * @Rest\QueryParam(name="orderBy", requirements="id|begin|end|rate", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, begin, end, rate (default: begin)")
* @Rest\QueryParam(name="orderBy", requirements="id|begin|end|rate", strict=true, nullable=true, description="The field by which results will be ordered (allowed values: 'id', 'begin', 'end', 'rate')") * @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: DESC)")
* @Rest\QueryParam(name="begin", requirements=@Constraints\DateTime, strict=true, nullable=true, description="Only records after this date will be included (format: Y-m-d H:i:s)") * @Rest\QueryParam(name="begin", requirements=@Constraints\DateTime, strict=true, nullable=true, description="Only records after this date will be included (format: ISO 8601)")
* @Rest\QueryParam(name="end", requirements=@Constraints\DateTime, strict=true, nullable=true, description="Only records before this date will be included (format: Y-m-d H:i:s)") * @Rest\QueryParam(name="end", requirements=@Constraints\DateTime, strict=true, nullable=true, description="Only records before this date will be included (format: ISO 8601)")
* @Rest\QueryParam(name="exported", requirements="0|1", strict=true, nullable=true, description="Use this flag if you want to filter for export state (0=not exported, 1=exported, null=all") * @Rest\QueryParam(name="exported", requirements="0|1", strict=true, nullable=true, description="Use this flag if you want to filter for export state. Allowed values: 0=not exported, 1=exported (default: all)")
* @Rest\QueryParam(name="active", requirements="0|1", strict=true, nullable=true, description="Filter for running/active records. Allowed values: 0=stopped, 1=active. (default: all)")
* *
* @Security("is_granted('view_own_timesheet') or is_granted('view_other_timesheet')") * @Security("is_granted('view_own_timesheet') or is_granted('view_other_timesheet')")
* *
@@ -141,6 +147,15 @@ class TimesheetController extends BaseApiController
$query->setEnd(new \DateTime($end)); $query->setEnd(new \DateTime($end));
} }
if (null !== ($active = $paramFetcher->get('active'))) {
$active = (int) $active;
if ($active === 1) {
$query->setState(TimesheetQuery::STATE_RUNNING);
} elseif ($active === 0) {
$query->setState(TimesheetQuery::STATE_STOPPED);
}
}
if (null !== ($exported = $paramFetcher->get('exported'))) { if (null !== ($exported = $paramFetcher->get('exported'))) {
$exported = (int) $exported; $exported = (int) $exported;
if ($exported === 1) { if ($exported === 1) {
@@ -161,35 +176,52 @@ class TimesheetController extends BaseApiController
} }
/** /**
* Returns one timesheet record
*
* @SWG\Response( * @SWG\Response(
* response=200, * response=200,
* description="Returns one timesheet entity", * description="Returns one timesheet record. Be aware that the datetime fields are given in the users local time including the timezone offset via ISO 8601.",
* @SWG\Schema(ref="#/definitions/TimesheetEntity") * @SWG\Schema(ref="#/definitions/TimesheetEntity")
* ) * )
* @SWG\Parameter(
* name="id",
* in="path",
* type="integer",
* description="Timesheet record ID to fetch",
* required=true,
* )
* *
* @Security("is_granted('view_own_timesheet')") * @Security("is_granted('view_own_timesheet') or is_granted('view_other_timesheet')")
* *
* @param int $id * @param int $id
* @return Response * @return Response
*/ */
public function getAction($id) public function getAction($id)
{ {
$data = $this->repository->find($id); $timesheet = $this->repository->find($id);
if (null === $data) {
if (null === $timesheet) {
throw new NotFoundException(); throw new NotFoundException();
} }
$view = new View($data, 200);
if (!$this->isGranted('view', $timesheet)) {
throw new AccessDeniedHttpException('You are not allowed to view this timesheet');
}
$view = new View($timesheet, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']); $view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
return $this->viewHandler->handle($view); return $this->viewHandler->handle($view);
} }
/** /**
* Creates a new timesheet record
*
* @SWG\Post( * @SWG\Post(
* description="Creates a new timesheet entry and returns it afterwards", * description="Creates a new timesheet record for the current user and returns it afterwards.",
* @SWG\Response( * @SWG\Response(
* response=200, * response=200,
* description="Returns the new created timesheet entry", * description="Returns the new created timesheet",
* @SWG\Schema(ref="#/definitions/TimesheetEntity"), * @SWG\Schema(ref="#/definitions/TimesheetEntity"),
* ) * )
* ) * )
@@ -218,20 +250,16 @@ class TimesheetController extends BaseApiController
'csrf_protection' => false, 'csrf_protection' => false,
'include_rate' => $this->isGranted('edit_rate', $timesheet), 'include_rate' => $this->isGranted('edit_rate', $timesheet),
'include_exported' => $this->isGranted('edit_export', $timesheet), 'include_exported' => $this->isGranted('edit_export', $timesheet),
'date_format' => self::DATE_FORMAT,
]); ]);
$form->submit($request->request->all()); $form->submit($request->request->all());
if ($form->isValid()) { if ($form->isValid()) {
if (null !== $timesheet->getId()) {
return new Response('This method does not support updates', Response::HTTP_BAD_REQUEST);
}
if (!$this->isGranted('start', $timesheet)) {
return new Response('You are not allowed to start this timesheet record', Response::HTTP_BAD_REQUEST);
}
if (null === $timesheet->getEnd()) { if (null === $timesheet->getEnd()) {
if (!$this->isGranted('start', $timesheet)) {
throw new AccessDeniedHttpException('You are not allowed to start this timesheet record');
}
$this->repository->stopActiveEntries( $this->repository->stopActiveEntries(
$timesheet->getUser(), $timesheet->getUser(),
$this->configuration->getActiveEntriesHardLimit() $this->configuration->getActiveEntriesHardLimit()
@@ -255,11 +283,13 @@ class TimesheetController extends BaseApiController
} }
/** /**
* Update an existing timesheet record
*
* @SWG\Patch( * @SWG\Patch(
* description="Update an existing timesheet entry, you can pass all or just a subset of all attributes", * description="Update an existing timesheet record, you can pass all or just a subset of the attributes.",
* @SWG\Response( * @SWG\Response(
* response=200, * response=200,
* description="Returns the updated timesheet entry", * description="Returns the updated timesheet",
* @SWG\Schema(ref="#/definitions/TimesheetEntity") * @SWG\Schema(ref="#/definitions/TimesheetEntity")
* ) * )
* ) * )
@@ -269,23 +299,35 @@ class TimesheetController extends BaseApiController
* required=true, * required=true,
* @SWG\Schema(ref="#/definitions/TimesheetEditForm") * @SWG\Schema(ref="#/definitions/TimesheetEditForm")
* ) * )
* @SWG\Parameter(
* name="id",
* in="path",
* type="integer",
* description="Timesheet record ID to update",
* required=true,
* )
* *
* @param Request $request * @param Request $request
* @param string $id * @param int $id the timesheet to update
* @return Response * @return Response
*/ */
public function patchAction(Request $request, string $id) public function patchAction(Request $request, int $id)
{ {
$timesheet = $this->repository->find($id); $timesheet = $this->repository->find($id);
if (null === $timesheet) {
throw new NotFoundException();
}
if (!$this->isGranted('edit', $timesheet)) { if (!$this->isGranted('edit', $timesheet)) {
throw $this->createAccessDeniedException('User cannot update timesheet'); throw new AccessDeniedHttpException('You are not allowed to update this timesheet');
} }
$form = $this->createForm(TimesheetEditForm::class, $timesheet, [ $form = $this->createForm(TimesheetEditForm::class, $timesheet, [
'csrf_protection' => false, 'csrf_protection' => false,
'include_rate' => $this->isGranted('edit_rate', $timesheet), 'include_rate' => $this->isGranted('edit_rate', $timesheet),
'include_exported' => $this->isGranted('edit_export', $timesheet), 'include_exported' => $this->isGranted('edit_export', $timesheet),
'date_format' => self::DATE_FORMAT,
]); ]);
$form->setData($timesheet); $form->setData($timesheet);

View File

@@ -11,17 +11,22 @@ declare(strict_types=1);
namespace App\API; namespace App\API;
use App\Entity\User; use App\Repository\Query\UserQuery;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource; use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View; use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface; use FOS\RestBundle\View\ViewHandlerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG; use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/** /**
* @RouteResource("User") * @RouteResource("User")
*
* @Security("is_granted('ROLE_USER')")
*/ */
class UserController extends BaseApiController class UserController extends BaseApiController
{ {
@@ -46,22 +51,46 @@ class UserController extends BaseApiController
} }
/** /**
* Returns the collection of all registered users
*
* @SWG\Response( * @SWG\Response(
* response=200, * response=200,
* description="Returns the collection of all registered users", * description="Returns the collection of all registered users. Required permission: view_user",
* @SWG\Schema( * @SWG\Schema(
* type="array", * type="array",
* @SWG\Items(ref="#/definitions/UserEntity") * @SWG\Items(ref="#/definitions/UserCollection")
* ) * )
* ) * )
* *
* @Rest\QueryParam(name="visible", requirements="1|2|3", strict=true, nullable=true, description="Visibility status to filter users. Allowed values: 1=visible, 2=hidden, 3=all (default: 1)")
* @Rest\QueryParam(name="orderBy", requirements="id|username|alias|email", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, username, alias, email (default: username)")
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)")
*
* @Security("is_granted('view_user')") * @Security("is_granted('view_user')")
* *
* @return Response * @return Response
*/ */
public function cgetAction() public function cgetAction(ParamFetcherInterface $paramFetcher)
{ {
$data = $this->repository->findAll(); $query = new UserQuery();
$query
->setResultType(UserQuery::RESULT_TYPE_OBJECTS)
->setOrderBy('username')
;
if (null !== ($visible = $paramFetcher->get('visible'))) {
$query->setVisibility($visible);
}
if (null !== ($order = $paramFetcher->get('order'))) {
$query->setOrder($order);
}
if (null !== ($orderBy = $paramFetcher->get('orderBy'))) {
$query->setOrderBy($orderBy);
}
$data = $this->repository->findByQuery($query);
$view = new View($data, 200); $view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection', 'User']); $view->getContext()->setGroups(['Default', 'Collection', 'User']);
@@ -69,24 +98,37 @@ class UserController extends BaseApiController
} }
/** /**
* Return one user entity
*
* @SWG\Response( * @SWG\Response(
* response=200, * response=200,
* description="Return one user entity", * description="Return one user entity. Required permission: view_user",
* @SWG\Schema(ref="#/definitions/UserEntity"), * @SWG\Schema(ref="#/definitions/UserEntity"),
* ) * )
* * @SWG\Parameter(
* @Security("is_granted('view_user')") * name="id",
* in="path",
* type="integer",
* description="User ID to fetch",
* required=true,
* )
* *
* @param int $id * @param int $id
* @return Response * @return Response
*/ */
public function getAction($id) public function getAction($id)
{ {
$data = $this->repository->find($id); $user = $this->repository->find($id);
if (null === $data) {
if (null === $user) {
throw new NotFoundException(); throw new NotFoundException();
} }
$view = new View($data, 200);
if (!$this->isGranted('view', $user)) {
throw new AccessDeniedHttpException('You are not allowed to view this profile');
}
$view = new View($user, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'User']); $view->getContext()->setGroups(['Default', 'Entity', 'User']);
return $this->viewHandler->handle($view); return $this->viewHandler->handle($view);

View File

@@ -222,7 +222,9 @@ class ActivityController extends AbstractController
return $this->createForm(ActivityEditForm::class, $activity, [ return $this->createForm(ActivityEditForm::class, $activity, [
'action' => $url, 'action' => $url,
'method' => 'POST' 'method' => 'POST',
'create_more' => true,
'customer' => true,
]); ]);
} }
} }

View File

@@ -214,14 +214,11 @@ class ProjectController extends AbstractController
$currency = $project->getCustomer()->getCurrency(); $currency = $project->getCustomer()->getCurrency();
} }
return $this->createForm( return $this->createForm(ProjectEditForm::class, $project, [
ProjectEditForm::class,
$project,
[
'action' => $url, 'action' => $url,
'method' => 'POST', 'method' => 'POST',
'currency' => $currency, 'currency' => $currency,
] 'create_more' => true,
); ]);
} }
} }

View File

@@ -267,6 +267,7 @@ class TimesheetController extends AbstractController
return $this->createForm(TimesheetEditForm::class, $entry, [ return $this->createForm(TimesheetEditForm::class, $entry, [
'action' => $this->generateUrl('timesheet_create', ['origin' => $redirectRoute]), 'action' => $this->generateUrl('timesheet_create', ['origin' => $redirectRoute]),
'include_rate' => $this->isGranted('edit_rate', $entry), 'include_rate' => $this->isGranted('edit_rate', $entry),
'customer' => true,
]); ]);
} }
@@ -286,6 +287,7 @@ class TimesheetController extends AbstractController
]), ]),
'include_rate' => $this->isGranted('edit_rate', $entry), 'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry), 'include_exported' => $this->isGranted('edit_export', $entry),
'customer' => true,
]); ]);
} }

View File

@@ -189,6 +189,7 @@ class TimesheetTeamController extends AbstractController
'action' => $this->generateUrl('admin_timesheet_create'), 'action' => $this->generateUrl('admin_timesheet_create'),
'include_rate' => $this->isGranted('edit_rate', $entry), 'include_rate' => $this->isGranted('edit_rate', $entry),
'include_user' => true, 'include_user' => true,
'customer' => true,
]); ]);
} }
@@ -208,6 +209,7 @@ class TimesheetTeamController extends AbstractController
'include_rate' => $this->isGranted('edit_rate', $entry), 'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry), 'include_exported' => $this->isGranted('edit_export', $entry),
'include_user' => true, 'include_user' => true,
'customer' => true,
]); ]);
} }

View File

@@ -170,6 +170,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
$start = new \DateTime(); $start = new \DateTime();
$start = $start->modify('- ' . (rand(1, self::TIMERANGE_DAYS)) . ' days'); $start = $start->modify('- ' . (rand(1, self::TIMERANGE_DAYS)) . ' days');
$start = $start->modify('- ' . (rand(1, 86400)) . ' seconds'); $start = $start->modify('- ' . (rand(1, 86400)) . ' seconds');
$start->setTimezone(new \DateTimeZone($user->getPreferenceValue(UserPreference::TIMEZONE, date_default_timezone_get())));
$entry = new Timesheet(); $entry = new Timesheet();
$entry $entry

View File

@@ -87,7 +87,7 @@ class UserFixtures extends Fixture
->setEnabled($userData[6]) ->setEnabled($userData[6])
->setPassword($passwordEncoder->encodePassword($user, self::DEFAULT_PASSWORD)) ->setPassword($passwordEncoder->encodePassword($user, self::DEFAULT_PASSWORD))
->setApiToken($passwordEncoder->encodePassword($user, self::DEFAULT_API_TOKEN)) ->setApiToken($passwordEncoder->encodePassword($user, self::DEFAULT_API_TOKEN))
->setPreferences([$this->getUserPreference($user)]) ->setPreferences($this->getUserPreferences($user, $userData[7]))
; ;
$manager->persist($user); $manager->persist($user);
@@ -99,16 +99,28 @@ class UserFixtures extends Fixture
/** /**
* @param User $user * @param User $user
* @return UserPreference * @param string|null $timezone
* @return array
*/ */
private function getUserPreference(user $user) private function getUserPreferences(User $user, string $timezone = null)
{ {
$preference = new UserPreference(); $preferences = [];
$preference->setName(UserPreference::HOURLY_RATE);
$preference->setValue(rand(self::MIN_RATE, self::MAX_RATE));
$preference->setUser($user);
return $preference; $prefHourlyRate = new UserPreference();
$prefHourlyRate->setName(UserPreference::HOURLY_RATE);
$prefHourlyRate->setValue(rand(self::MIN_RATE, self::MAX_RATE));
$prefHourlyRate->setUser($user);
$preferences[] = $prefHourlyRate;
if (null !== $timezone) {
$prefTimezone = new UserPreference();
$prefTimezone->setName(UserPreference::TIMEZONE);
$prefTimezone->setValue($timezone);
$prefTimezone->setUser($user);
$preferences[] = $prefTimezone;
}
return $preferences;
} }
/** /**
@@ -132,7 +144,7 @@ class UserFixtures extends Fixture
->setAvatar(self::DEFAULT_AVATAR) ->setAvatar(self::DEFAULT_AVATAR)
->setEnabled(true) ->setEnabled(true)
->setPassword($passwordEncoder->encodePassword($user, self::DEFAULT_PASSWORD)) ->setPassword($passwordEncoder->encodePassword($user, self::DEFAULT_PASSWORD))
->setPreferences([$this->getUserPreference($user)]) ->setPreferences($this->getUserPreferences($user))
; ;
if ($i % self::BATCH_SIZE == 0) { if ($i % self::BATCH_SIZE == 0) {
@@ -152,28 +164,68 @@ class UserFixtures extends Fixture
*/ */
protected function getUserDefinition() protected function getUserDefinition()
{ {
// alias = $userData[0]
// title = $userData[1]
// username = $userData[2]
// email = $userData[3]
// roles = [$userData[4]]
// avatar = $userData[5]
// enabled = $userData[6]
// timezone = $userData[7]
return [ return [
[ [
'John Doe', 'Developer', self::USERNAME_USER, 'john_user@example.com', User::ROLE_USER, 'John Doe',
self::DEFAULT_AVATAR, true 'Developer',
self::USERNAME_USER,
'john_user@example.com',
User::ROLE_USER,
self::DEFAULT_AVATAR,
true,
'America/Vancouver',
], ],
// inactive user to test login // inactive user to test login
[ [
'Chris Deactive', 'Developer (left company)', 'chris_user', 'chris_user@example.com', User::ROLE_USER, 'Chris Deactive',
self::DEFAULT_AVATAR, false 'Developer (left company)',
'chris_user',
'chris_user@example.com',
User::ROLE_USER,
self::DEFAULT_AVATAR,
false,
'Australia/Sydney',
], ],
[ [
'Tony Maier', 'Head of Sales', self::USERNAME_TEAMLEAD, 'tony_teamlead@example.com', User::ROLE_TEAMLEAD, 'Tony Maier',
'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg', true 'Head of Sales',
self::USERNAME_TEAMLEAD,
'tony_teamlead@example.com',
User::ROLE_TEAMLEAD,
'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg',
true,
'Asia/Bangkok',
], ],
// no avatar to test default image macro // no avatar to test default image macro
[ [
'Anna Smith', 'Administrator', self::USERNAME_ADMIN, 'anna_admin@example.com', User::ROLE_ADMIN, null, true 'Anna Smith',
'Administrator',
self::USERNAME_ADMIN,
'anna_admin@example.com',
User::ROLE_ADMIN,
null,
true,
'Europe/London',
], ],
// no alias to test twig username macro // no alias to test twig username macro
[ [
null, 'Super Administrator', self::USERNAME_SUPER_ADMIN, 'susan_super@example.com', User::ROLE_SUPER_ADMIN, null,
'/build/images/default_avatar.png', true 'Super Administrator',
self::USERNAME_SUPER_ADMIN,
'susan_super@example.com',
User::ROLE_SUPER_ADMIN,
'/build/images/default_avatar.png',
true,
'Europe/Berlin',
] ]
]; ];
} }

View File

@@ -11,13 +11,14 @@ namespace App\Form;
use App\Entity\Activity; use App\Entity\Activity;
use App\Form\Type\CustomerType; use App\Form\Type\CustomerType;
use App\Form\Type\FixedRateType;
use App\Form\Type\HourlyRateType;
use App\Form\Type\ProjectType; use App\Form\Type\ProjectType;
use App\Form\Type\YesNoType; use App\Form\Type\YesNoType;
use App\Repository\CustomerRepository; use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
@@ -66,8 +67,11 @@ class ActivityEditForm extends AbstractType
'label' => 'label.comment', 'label' => 'label.comment',
'required' => false, 'required' => false,
]) ])
;
if ($options['customer']) {
$builder
->add('customer', CustomerType::class, [ ->add('customer', CustomerType::class, [
'label' => 'label.customer',
'query_builder' => function (CustomerRepository $repo) use ($customer) { 'query_builder' => function (CustomerRepository $repo) use ($customer) {
return $repo->builderForEntityType($customer); return $repo->builderForEntityType($customer);
}, },
@@ -75,9 +79,11 @@ class ActivityEditForm extends AbstractType
'required' => false, 'required' => false,
'mapped' => false, 'mapped' => false,
'project_enabled' => true, 'project_enabled' => true,
]) ]);
}
$builder
->add('project', ProjectType::class, [ ->add('project', ProjectType::class, [
'label' => 'label.project',
'required' => false, 'required' => false,
'query_builder' => function (ProjectRepository $repo) use ($project, $customer) { 'query_builder' => function (ProjectRepository $repo) use ($project, $customer) {
return $repo->builderForEntityType($project, $customer); return $repo->builderForEntityType($project, $customer);
@@ -103,14 +109,10 @@ class ActivityEditForm extends AbstractType
); );
$builder $builder
->add('fixedRate', MoneyType::class, [ ->add('fixedRate', FixedRateType::class, [
'label' => 'label.fixedRate',
'required' => false,
'currency' => $currency, 'currency' => $currency,
]) ])
->add('hourlyRate', MoneyType::class, [ ->add('hourlyRate', HourlyRateType::class, [
'label' => 'label.hourlyRate',
'required' => false,
'currency' => $currency, 'currency' => $currency,
]) ])
// boolean // boolean
@@ -119,7 +121,7 @@ class ActivityEditForm extends AbstractType
]) ])
; ;
if (null === $id) { if (null === $id && $options['create_more']) {
$builder->add('create_more', CheckboxType::class, [ $builder->add('create_more', CheckboxType::class, [
'label' => 'label.create_more', 'label' => 'label.create_more',
'required' => false, 'required' => false,
@@ -138,6 +140,8 @@ class ActivityEditForm extends AbstractType
'csrf_protection' => true, 'csrf_protection' => true,
'csrf_field_name' => '_token', 'csrf_field_name' => '_token',
'csrf_token_id' => 'admin_activity_edit', 'csrf_token_id' => 'admin_activity_edit',
'create_more' => false,
'customer' => false,
]); ]);
} }
} }

View File

@@ -10,12 +10,13 @@
namespace App\Form; namespace App\Form;
use App\Entity\Customer; use App\Entity\Customer;
use App\Form\Type\FixedRateType;
use App\Form\Type\HourlyRateType;
use App\Form\Type\YesNoType; use App\Form\Type\YesNoType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CountryType; use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\CurrencyType; use Symfony\Component\Form\Extension\Core\Type\CurrencyType;
use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TelType; use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
@@ -34,8 +35,13 @@ class CustomerEditForm extends AbstractType
*/ */
public function buildForm(FormBuilderInterface $builder, array $options) public function buildForm(FormBuilderInterface $builder, array $options)
{ {
$currency = false;
if (isset($options['data'])) {
/** @var Customer $customer */ /** @var Customer $customer */
$customer = $options['data']; $customer = $options['data'];
$currency = $customer->getCurrency();
}
$builder $builder
->add('name', TextType::class, [ ->add('name', TextType::class, [
@@ -95,15 +101,11 @@ class CustomerEditForm extends AbstractType
->add('timezone', TimezoneType::class, [ ->add('timezone', TimezoneType::class, [
'label' => 'label.timezone', 'label' => 'label.timezone',
]) ])
->add('fixedRate', MoneyType::class, [ ->add('fixedRate', FixedRateType::class, [
'label' => 'label.fixedRate', 'currency' => $currency ?? false,
'required' => false,
'currency' => $customer->getCurrency() ?? false,
]) ])
->add('hourlyRate', MoneyType::class, [ ->add('hourlyRate', HourlyRateType::class, [
'label' => 'label.hourlyRate', 'currency' => $currency ?? false,
'required' => false,
'currency' => $customer->getCurrency() ?? false,
]) ])
->add('visible', YesNoType::class, [ ->add('visible', YesNoType::class, [
'label' => 'label.visible', 'label' => 'label.visible',

View File

@@ -12,6 +12,8 @@ namespace App\Form;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
use App\Form\Type\CustomerType; use App\Form\Type\CustomerType;
use App\Form\Type\FixedRateType;
use App\Form\Type\HourlyRateType;
use App\Form\Type\YesNoType; use App\Form\Type\YesNoType;
use App\Repository\CustomerRepository; use App\Repository\CustomerRepository;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
@@ -32,16 +34,20 @@ class ProjectEditForm extends AbstractType
*/ */
public function buildForm(FormBuilderInterface $builder, array $options) public function buildForm(FormBuilderInterface $builder, array $options)
{ {
/** @var Project $entry */
$entry = $options['data'];
$customer = null; $customer = null;
$currency = false; $currency = false;
$id = null;
if ($entry->getId() !== null) { if (isset($options['data'])) {
/** @var Project $entry */
$entry = $options['data'];
$id = $entry->getId();
if ($id !== null) {
$customer = $entry->getCustomer(); $customer = $entry->getCustomer();
$currency = $customer->getCurrency(); $currency = $customer->getCurrency();
} }
}
$builder $builder
->add('name', TextType::class, [ ->add('name', TextType::class, [
@@ -59,19 +65,14 @@ class ProjectEditForm extends AbstractType
'required' => false, 'required' => false,
]) ])
->add('customer', CustomerType::class, [ ->add('customer', CustomerType::class, [
'label' => 'label.customer',
'query_builder' => function (CustomerRepository $repo) use ($customer) { 'query_builder' => function (CustomerRepository $repo) use ($customer) {
return $repo->builderForEntityType($customer); return $repo->builderForEntityType($customer);
}, },
]) ])
->add('fixedRate', MoneyType::class, [ ->add('fixedRate', FixedRateType::class, [
'label' => 'label.fixedRate',
'required' => false,
'currency' => $currency, 'currency' => $currency,
]) ])
->add('hourlyRate', MoneyType::class, [ ->add('hourlyRate', HourlyRateType::class, [
'label' => 'label.hourlyRate',
'required' => false,
'currency' => $currency, 'currency' => $currency,
]) ])
->add('budget', MoneyType::class, [ ->add('budget', MoneyType::class, [
@@ -84,7 +85,7 @@ class ProjectEditForm extends AbstractType
]) ])
; ;
if ($entry->getId() === null) { if (null === $id && $options['create_more']) {
$builder->add('create_more', CheckboxType::class, [ $builder->add('create_more', CheckboxType::class, [
'label' => 'label.create_more', 'label' => 'label.create_more',
'required' => false, 'required' => false,
@@ -104,6 +105,7 @@ class ProjectEditForm extends AbstractType
'csrf_field_name' => '_token', 'csrf_field_name' => '_token',
'csrf_token_id' => 'admin_project_edit', 'csrf_token_id' => 'admin_project_edit',
'currency' => Customer::DEFAULT_CURRENCY, 'currency' => Customer::DEFAULT_CURRENCY,
'create_more' => false,
]); ]);
} }
} }

View File

@@ -15,6 +15,8 @@ use App\Form\Type\ActivityType;
use App\Form\Type\CustomerType; use App\Form\Type\CustomerType;
use App\Form\Type\DateTimePickerType; use App\Form\Type\DateTimePickerType;
use App\Form\Type\DurationType; use App\Form\Type\DurationType;
use App\Form\Type\FixedRateType;
use App\Form\Type\HourlyRateType;
use App\Form\Type\ProjectType; use App\Form\Type\ProjectType;
use App\Form\Type\UserType; use App\Form\Type\UserType;
use App\Form\Type\YesNoType; use App\Form\Type\YesNoType;
@@ -23,7 +25,6 @@ use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Timesheet\UserDateTimeFactory; use App\Timesheet\UserDateTimeFactory;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvent;
@@ -77,6 +78,7 @@ class TimesheetEditForm extends AbstractType
$currency = false; $currency = false;
$end = null; $end = null;
$begin = null; $begin = null;
$customerCount = $this->customers->countCustomer(true);
if (isset($options['data'])) { if (isset($options['data'])) {
/** @var Timesheet $entry */ /** @var Timesheet $entry */
@@ -104,12 +106,20 @@ class TimesheetEditForm extends AbstractType
$timezone = $begin->getTimezone()->getName(); $timezone = $begin->getTimezone()->getName();
} }
if (null === $end || !$this->configuration->isDurationOnly()) { $dateTimeOptions = [
$builder->add('begin', DateTimePickerType::class, [
'label' => 'label.begin',
'model_timezone' => $timezone, 'model_timezone' => $timezone,
'view_timezone' => $timezone, 'view_timezone' => $timezone,
]); ];
// primarily for API usage, where we cannot use a user/locale specific format
if (null !== $options['date_format']) {
$dateTimeOptions['format'] = $options['date_format'];
}
if (null === $end || !$this->configuration->isDurationOnly()) {
$builder->add('begin', DateTimePickerType::class, array_merge($dateTimeOptions, [
'label' => 'label.begin'
]));
} }
if ($this->configuration->isDurationOnly()) { if ($this->configuration->isDurationOnly()) {
@@ -126,7 +136,7 @@ class TimesheetEditForm extends AbstractType
function (FormEvent $event) { function (FormEvent $event) {
/** @var Timesheet $data */ /** @var Timesheet $data */
$data = $event->getData(); $data = $event->getData();
if (null === $data->getEnd()) { if (null === $data || null === $data->getEnd()) {
$event->getForm()->get('duration')->setData(null); $event->getForm()->get('duration')->setData(null);
} }
} }
@@ -148,24 +158,19 @@ class TimesheetEditForm extends AbstractType
} }
); );
} else { } else {
$builder->add('end', DateTimePickerType::class, [ $builder->add('end', DateTimePickerType::class, array_merge($dateTimeOptions, [
'label' => 'label.end', 'label' => 'label.end',
'model_timezone' => $timezone,
'view_timezone' => $timezone,
'required' => false, 'required' => false,
]); ]));
} }
$projectOptions = []; $projectOptions = [];
if ($this->customers->countCustomer(true) > 1) { if ($customerCount < 2) {
$projectOptions['group_by'] = null;
} elseif ($options['customer']) {
$builder $builder
->add('customer', CustomerType::class, [ ->add('customer', CustomerType::class, [
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'integer',
'description' => 'Customer ID',
],
'query_builder' => function (CustomerRepository $repo) use ($customer) { 'query_builder' => function (CustomerRepository $repo) use ($customer) {
return $repo->builderForEntityType($customer); return $repo->builderForEntityType($customer);
}, },
@@ -175,8 +180,6 @@ class TimesheetEditForm extends AbstractType
'mapped' => false, 'mapped' => false,
'project_enabled' => true, 'project_enabled' => true,
]); ]);
} else {
$projectOptions['group_by'] = null;
} }
if ($this->projects->countProject(true) <= 1) { if ($this->projects->countProject(true) <= 1) {
@@ -190,11 +193,6 @@ class TimesheetEditForm extends AbstractType
array_merge($projectOptions, [ array_merge($projectOptions, [
'placeholder' => '', 'placeholder' => '',
'activity_enabled' => true, 'activity_enabled' => true,
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'integer',
'description' => 'Project ID',
],
'query_builder' => function (ProjectRepository $repo) use ($project, $customer) { 'query_builder' => function (ProjectRepository $repo) use ($project, $customer) {
return $repo->builderForEntityType($project, $customer); return $repo->builderForEntityType($project, $customer);
}, },
@@ -223,12 +221,7 @@ class TimesheetEditForm extends AbstractType
$builder $builder
->add('activity', ActivityType::class, [ ->add('activity', ActivityType::class, [
// documentation is for NelmioApiDocBundle
'placeholder' => '', 'placeholder' => '',
'documentation' => [
'type' => 'integer',
'description' => 'Activity ID',
],
'query_builder' => function (ActivityRepository $repo) use ($activity, $project) { 'query_builder' => function (ActivityRepository $repo) use ($activity, $project) {
return $repo->builderForEntityType($activity, $project); return $repo->builderForEntityType($activity, $project);
}, },
@@ -262,20 +255,10 @@ class TimesheetEditForm extends AbstractType
if ($options['include_rate']) { if ($options['include_rate']) {
$builder $builder
->add('fixedRate', MoneyType::class, [ ->add('fixedRate', FixedRateType::class, [
'documentation' => [
'type' => 'float'
],
'label' => 'label.fixedRate',
'required' => false,
'currency' => $currency, 'currency' => $currency,
]) ])
->add('hourlyRate', MoneyType::class, [ ->add('hourlyRate', HourlyRateType::class, [
'documentation' => [
'type' => 'float'
],
'label' => 'label.hourlyRate',
'required' => false,
'currency' => $currency, 'currency' => $currency,
]); ]);
} }
@@ -306,6 +289,8 @@ class TimesheetEditForm extends AbstractType
'include_rate' => true, 'include_rate' => true,
'docu_chapter' => 'timesheet.html', 'docu_chapter' => 'timesheet.html',
'method' => 'POST', 'method' => 'POST',
'date_format' => null,
'customer' => false,
]); ]);
} }
} }

View File

@@ -67,6 +67,11 @@ class ActivityType extends AbstractType
public function configureOptions(OptionsResolver $resolver) public function configureOptions(OptionsResolver $resolver)
{ {
$resolver->setDefaults([ $resolver->setDefaults([
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'integer',
'description' => 'Activity ID',
],
'label' => 'label.activity', 'label' => 'label.activity',
'class' => Activity::class, 'class' => Activity::class,
'choice_label' => [$this, 'choiceLabel'], 'choice_label' => [$this, 'choiceLabel'],

View File

@@ -28,6 +28,11 @@ class CustomerType extends AbstractType
public function configureOptions(OptionsResolver $resolver) public function configureOptions(OptionsResolver $resolver)
{ {
$resolver->setDefaults([ $resolver->setDefaults([
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'integer',
'description' => 'Customer ID',
],
'label' => 'label.customer', 'label' => 'label.customer',
'class' => Customer::class, 'class' => Customer::class,
'choice_label' => 'name', 'choice_label' => 'name',

View File

@@ -9,6 +9,7 @@
namespace App\Form\Type; namespace App\Form\Type;
use App\API\BaseApiController;
use App\Timesheet\UserDateTimeFactory; use App\Timesheet\UserDateTimeFactory;
use App\Utils\LocaleSettings; use App\Utils\LocaleSettings;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
@@ -50,6 +51,11 @@ class DateTimePickerType extends AbstractType
$timezone = $this->dateTime->getTimezone()->getName(); $timezone = $this->dateTime->getTimezone()->getName();
$resolver->setDefaults([ $resolver->setDefaults([
'documentation' => [
'type' => 'string',
'format' => 'date-time',
'example' => (new \DateTime())->format(BaseApiController::DATE_FORMAT_PHP),
],
'label' => 'label.begin', 'label' => 'label.begin',
'widget' => 'single_text', 'widget' => 'single_text',
'html5' => false, 'html5' => false,

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to set the fixed rate.
*/
class FixedRateType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'number',
'description' => 'Fixed rate',
],
'required' => false,
'label' => 'label.fixedRate',
]);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return MoneyType::class;
}
}

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to set the hourly rate.
*/
class HourlyRateType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'number',
'description' => 'Hourly rate',
],
'required' => false,
'label' => 'label.hourlyRate',
]);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return MoneyType::class;
}
}

View File

@@ -49,6 +49,11 @@ class ProjectType extends AbstractType
public function configureOptions(OptionsResolver $resolver) public function configureOptions(OptionsResolver $resolver)
{ {
$resolver->setDefaults([ $resolver->setDefaults([
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'integer',
'description' => 'Project ID',
],
'label' => 'label.project', 'label' => 'label.project',
'class' => Project::class, 'class' => Project::class,
'choice_label' => 'name', 'choice_label' => 'name',

View File

@@ -16,14 +16,12 @@ use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/** /**
* Defines the form used to edit the profile of a User. * Defines the form used to edit the profile of a User.
*/ */
class UserEditType extends AbstractType class UserEditType extends AbstractType
{ {
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */

View File

@@ -43,7 +43,7 @@ class BaseQuery
/** /**
* @var string * @var string
*/ */
protected $order = 'ASC'; protected $order = self::ORDER_ASC;
/** /**
* @var string * @var string
*/ */

View File

@@ -50,7 +50,7 @@ class UserRepository extends AbstractRepository implements UserLoaderInterface
/** /**
* @param UserQuery $query * @param UserQuery $query
* @return \Pagerfanta\Pagerfanta * @return array|\Doctrine\ORM\QueryBuilder|\Pagerfanta\Pagerfanta
*/ */
public function findByQuery(UserQuery $query) public function findByQuery(UserQuery $query)
{ {
@@ -77,7 +77,7 @@ class UserRepository extends AbstractRepository implements UserLoaderInterface
$qb->andWhere($rolesWhere); $qb->andWhere($rolesWhere);
} }
return $this->getPager($qb->getQuery(), $query->getPage(), $query->getPageSize()); return $this->getBaseQueryResult($qb, $query);
} }
/** /**

View File

@@ -18,6 +18,7 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
*/ */
class TimesheetVoter extends AbstractVoter class TimesheetVoter extends AbstractVoter
{ {
public const VIEW = 'view';
public const START = 'start'; public const START = 'start';
public const STOP = 'stop'; public const STOP = 'stop';
public const EDIT = 'edit'; public const EDIT = 'edit';
@@ -31,6 +32,7 @@ class TimesheetVoter extends AbstractVoter
* support rules based on the given $subject (here: Timesheet) * support rules based on the given $subject (here: Timesheet)
*/ */
public const ALLOWED_ATTRIBUTES = [ public const ALLOWED_ATTRIBUTES = [
self::VIEW,
self::START, self::START,
self::STOP, self::STOP,
self::EDIT, self::EDIT,
@@ -87,6 +89,7 @@ class TimesheetVoter extends AbstractVoter
case self::EDIT_RATE: case self::EDIT_RATE:
case self::STOP: case self::STOP:
case self::EDIT: case self::EDIT:
case self::VIEW:
case self::DELETE: case self::DELETE:
case self::EXPORT: case self::EXPORT:
case self::EDIT_EXPORT: case self::EDIT_EXPORT:

View File

@@ -143,6 +143,45 @@
"gedmo/doctrine-extensions": { "gedmo/doctrine-extensions": {
"version": "v2.4.36" "version": "v2.4.36"
}, },
"hoa/compiler": {
"version": "3.17.08.08"
},
"hoa/consistency": {
"version": "1.17.05.02"
},
"hoa/event": {
"version": "1.17.01.13"
},
"hoa/exception": {
"version": "1.17.01.16"
},
"hoa/file": {
"version": "1.17.07.11"
},
"hoa/iterator": {
"version": "2.17.01.10"
},
"hoa/math": {
"version": "1.17.05.16"
},
"hoa/protocol": {
"version": "1.17.01.14"
},
"hoa/regex": {
"version": "1.17.01.13"
},
"hoa/stream": {
"version": "1.17.02.21"
},
"hoa/ustring": {
"version": "4.17.01.16"
},
"hoa/visitor": {
"version": "2.17.01.16"
},
"hoa/zformat": {
"version": "1.17.01.10"
},
"jdorn/sql-formatter": { "jdorn/sql-formatter": {
"version": "v1.2.17" "version": "v1.2.17"
}, },

View File

@@ -15,6 +15,7 @@
<ul class="nav nav-tabs" role="tablist"> <ul class="nav nav-tabs" role="tablist">
<li role="presentation"class ="active"><a href="#systeminfo" aria-controls="systeminfo" role="tab" data-toggle="tab">{{ 'tab.system'|trans({}, 'about') }}</a></li> <li role="presentation"class ="active"><a href="#systeminfo" aria-controls="systeminfo" role="tab" data-toggle="tab">{{ 'tab.system'|trans({}, 'about') }}</a></li>
<li role="presentation"><a href="#license" aria-controls="license" role="tab" data-toggle="tab">{{ 'tab.license'|trans({}, 'about') }}</a></li> <li role="presentation"><a href="#license" aria-controls="license" role="tab" data-toggle="tab">{{ 'tab.license'|trans({}, 'about') }}</a></li>
<li role="presentation"><a href="#thanks" aria-controls="thanks" role="tab" data-toggle="tab">{{ 'tab.thanks'|trans({}, 'about') }}</a></li>
</ul> </ul>
<div class="tab-content"> <div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="systeminfo"> <div role="tabpanel" class="tab-pane active" id="systeminfo">
@@ -123,6 +124,24 @@
<a href="https://en.wikipedia.org/wiki/MIT_License" target="_blank">wikipedia.org</a> <a href="https://en.wikipedia.org/wiki/MIT_License" target="_blank">wikipedia.org</a>
</p> </p>
</div> </div>
<div role="tabpanel" class="tab-pane" id="thanks">
<p>Special thanks to the authors of the following libraries, Kimai wouldn't be possible without them:</p>
<ul>
<li>AdminLTE: <a href="https://adminlte.io" target="_blank">https://adminlte.io</a></li>
<li>Bootstrap: <a href="https://getbootstrap.com" target="_blank">https://getbootstrap.com</a></li>
<li>Bootstrap-Select: <a href="https://developer.snapappointments.com/bootstrap-select" target="_blank">https://developer.snapappointments.com/bootstrap-select</a></li>
<li>chart.js: <a href="https://www.chartjs.org" target="_blank">https://www.chartjs.org</a></li>
<li>DateRangePicker: <a href="http://www.daterangepicker.com/" target="_blank">http://www.daterangepicker.com/</a></li>
<li>Doctrine: <a href="https://www.doctrine-project.org" target="_blank">https://www.doctrine-project.org</a></li>
<li>FontAwesome: <a href="https://fontawesome.com" target="_blank">https://fontawesome.com</a></li>
<li>FullCalendar: <a href="https://fullcalendar.io" target="_blank">https://fullcalendar.io</a></li>
<li>jQuery: <a href="https://jquery.com" target="_blank">https://jquery.com</a></li>
<li>jQuery UI: <a href="https://jqueryui.com" target="_blank">http://jqueryui.com</a></li>
<li>MomentJS: <a href="https://momentjs.com" target="_blank">https://momentjs.com</a></li>
<li>Symfony: <a href="https://symfony.com" target="_blank">https://symfony.com</a></li>
<li>...</li>
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,18 +1,9 @@
<!DOCTYPE html> {% extends '@!NelmioApiDoc/SwaggerUi/index.html.twig' %}
<html>
<head> {% block stylesheets %}
<meta charset="UTF-8"> {{ parent() }}
<title>{{ swagger_data.spec.info.title }}</title> <style type="text/css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans:400,700|Source+Code+Pro:300,600|Titillium+Web:400,600,700"> body { margin-top: 0; }
<link rel="stylesheet" href="{{ asset('bundles/nelmioapidoc/swagger-ui/swagger-ui.css') }}"> header { display:none; }
<link rel="stylesheet" href="{{ asset('bundles/nelmioapidoc/style.css') }}"> </style>
{# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #} {% endblock %}
<script id="swagger-data" type="application/json">{{ swagger_data|json_encode(65)|raw }}</script>
</head>
<body style="margin-top:0;">
<div id="swagger-ui" class="api-platform"></div>
<script src="{{ asset('bundles/nelmioapidoc/swagger-ui/swagger-ui-bundle.js') }}"></script>
<script src="{{ asset('bundles/nelmioapidoc/swagger-ui/swagger-ui-standalone-preset.js') }}"></script>
<script src="{{ asset('bundles/nelmioapidoc/init-swagger-ui.js') }}"></script>
</body>
</html>

View File

@@ -171,6 +171,47 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
); );
} }
/**
* @param string $role
* @param string $url
* @param array $data
*/
protected function assertEntityNotFoundForPatch(string $role, string $url, array $data)
{
$client = $this->getClientForAuthenticatedUser($role);
$this->request($client, $url, 'PATCH', [], 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 Client $client
* @param string $url
* @param string $message
*/
protected function assertApiAccessDenied(Client $client, string $url, string $message)
{
$this->request($client, $url);
$response = $client->getResponse();
$this->assertFalse($response->isSuccessful());
$this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode());
$expected = ['code' => Response::HTTP_FORBIDDEN, 'message' => $message];
$this->assertEquals($expected, json_decode($response->getContent(), true));
}
/** /**
* @param Response $response * @param Response $response
* @param string[] $failedFields * @param string[] $failedFields

View File

@@ -13,7 +13,6 @@ use App\Entity\Activity;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\User; use App\Entity\User;
use App\Repository\Query\VisibilityQuery;
use Symfony\Bundle\FrameworkBundle\Client; use Symfony\Bundle\FrameworkBundle\Client;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -77,7 +76,7 @@ class ActivityControllerTest extends APIControllerBaseTest
for ($i = 0; $i < count($result); $i++) { for ($i = 0; $i < count($result); $i++) {
$activity = $result[$i]; $activity = $result[$i];
$hasProject = $expected[$i][0]; $hasProject = $expected[$i][0];
$this->assertStructure($activity, $hasProject); $this->assertStructure($activity, false);
if ($hasProject) { if ($hasProject) {
$this->assertEquals($expected[$i][0], $activity['project']); $this->assertEquals($expected[$i][0], $activity['project']);
} }
@@ -88,13 +87,13 @@ class ActivityControllerTest extends APIControllerBaseTest
{ {
yield ['/api/activities', [], [[false], [false], [true, 2], [true, 1], [true, 2]]]; yield ['/api/activities', [], [[false], [false], [true, 2], [true, 1], [true, 2]]];
yield ['/api/activities', ['globals' => 'true'], [[false], [false]]]; yield ['/api/activities', ['globals' => 'true'], [[false], [false]]];
yield ['/api/activities', ['globals' => 'true', 'visible' => VisibilityQuery::SHOW_BOTH], [[false], [false], [false]]]; yield ['/api/activities', ['globals' => 'true', 'visible' => 3], [[false], [false], [false]]];
yield ['/api/activities', ['globals' => 'true', 'visible' => VisibilityQuery::SHOW_HIDDEN], [[false]]]; yield ['/api/activities', ['globals' => 'true', 'visible' => '2'], [[false]]];
yield ['/api/activities', ['globals' => 'true', 'visible' => VisibilityQuery::SHOW_VISIBLE], [[false], [false]]]; yield ['/api/activities', ['globals' => 'true', 'visible' => 1], [[false], [false]]];
yield ['/api/activities', ['project' => '1'], [[false], [false], [true, 1]]]; yield ['/api/activities', ['project' => '1'], [[false], [false], [true, 1]]];
yield ['/api/activities', ['project' => '2', 'visible' => VisibilityQuery::SHOW_VISIBLE], [[false], [false], [true, 2], [true, 2]]]; yield ['/api/activities', ['project' => '2', 'visible' => 1], [[false], [false], [true, 2], [true, 2]]];
yield ['/api/activities', ['project' => '2', 'visible' => VisibilityQuery::SHOW_BOTH], [[false], [false], [false], [true, 2], [true, 2], [true, 2]]]; yield ['/api/activities', ['project' => '2', 'visible' => '3'], [[false], [false], [false], [true, 2], [true, 2], [true, 2]]];
yield ['/api/activities', ['project' => '2', 'visible' => VisibilityQuery::SHOW_HIDDEN], [[false], [true, 2]]]; yield ['/api/activities', ['project' => '2', 'visible' => 2], [[false], [true, 2]]];
} }
public function testGetCollectionWithQuery() public function testGetCollectionWithQuery()
@@ -123,10 +122,12 @@ class ActivityControllerTest extends APIControllerBaseTest
$result = json_decode($client->getResponse()->getContent(), true); $result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result); $this->assertIsArray($result);
$this->assertStructure($result, true);
}
$expectedKeys = ['id', 'name', 'comment', 'visible']; public function testNotFound()
$actual = array_keys($result); {
$this->assertEquals($expectedKeys, $actual); $this->assertEntityNotFound(User::ROLE_USER, '/api/activities/2');
} }
public function testPostAction() public function testPostAction()
@@ -134,7 +135,6 @@ class ActivityControllerTest extends APIControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$data = [ $data = [
'name' => 'foo', 'name' => 'foo',
'customer' => 1,
'project' => 1, 'project' => 1,
'visible' => true 'visible' => true
]; ];
@@ -152,7 +152,6 @@ class ActivityControllerTest extends APIControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$data = [ $data = [
'name' => 'foo', 'name' => 'foo',
'customer' => 1,
'project' => 1, 'project' => 1,
'visible' => true 'visible' => true
]; ];
@@ -164,18 +163,12 @@ class ActivityControllerTest extends APIControllerBaseTest
$this->assertEquals('User cannot create activities', $json['message']); $this->assertEquals('User cannot create activities', $json['message']);
} }
public function testNotFound()
{
$this->assertEntityNotFound(User::ROLE_USER, '/api/activities/2');
}
public function testPatchAction() public function testPatchAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$data = [ $data = [
'name' => 'foo', 'name' => 'foo',
'comment' => '', 'comment' => '',
'customer' => 1,
'project' => 1, 'project' => 1,
'visible' => true 'visible' => true
]; ];
@@ -195,11 +188,10 @@ class ActivityControllerTest extends APIControllerBaseTest
$data = [ $data = [
'name' => 'foo', 'name' => 'foo',
'comment' => '', 'comment' => '',
'customer' => 1,
'project' => 1, 'project' => 1,
'visible' => true 'visible' => true
]; ];
$this->request($client, '/api/activities/15', 'PATCH', [], json_encode($data)); $this->request($client, '/api/activities/1', 'PATCH', [], json_encode($data));
$response = $client->getResponse(); $response = $client->getResponse();
$this->assertFalse($response->isSuccessful()); $this->assertFalse($response->isSuccessful());
$this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode()); $this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode());
@@ -207,13 +199,17 @@ class ActivityControllerTest extends APIControllerBaseTest
$this->assertEquals('User cannot update activity', $json['message']); $this->assertEquals('User cannot update activity', $json['message']);
} }
public function testPatchActionWithUnknownActivity()
{
$this->assertEntityNotFoundForPatch(User::ROLE_USER, '/api/activities/255', []);
}
public function testInvalidPatchAction() public function testInvalidPatchAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$data = [ $data = [
'name' => 'foo', 'name' => 'foo',
'customer' => 255, 'project' => 255,
'project' => 1,
'visible' => true 'visible' => true
]; ];
$this->request($client, '/api/activities/1', 'PATCH', [], json_encode($data)); $this->request($client, '/api/activities/1', 'PATCH', [], json_encode($data));
@@ -225,10 +221,10 @@ class ActivityControllerTest extends APIControllerBaseTest
protected function assertStructure(array $result, $full = true) protected function assertStructure(array $result, $full = true)
{ {
$expectedKeys = ['id', 'name', 'visible']; $expectedKeys = ['id', 'name', 'visible', 'project', 'hourlyRate', 'fixedRate'];
if ($full) { if ($full) {
$expectedKeys = ['id', 'name', 'visible', 'project']; $expectedKeys = array_merge($expectedKeys, ['comment']);
} }
$actual = array_keys($result); $actual = array_keys($result);

View File

@@ -36,7 +36,7 @@ class ConfigurationControllerTest extends APIControllerBaseTest
protected function assertStructure(array $result) protected function assertStructure(array $result)
{ {
$expectedKeys = ['date', 'date_time', 'duration', 'form_date', 'form_date_time', 'is24hours', 'time']; $expectedKeys = ['date', 'dateTime', 'duration', 'formDate', 'formDateTime', 'is24hours', 'time'];
$actual = array_keys($result); $actual = array_keys($result);
sort($actual); sort($actual);
sort($expectedKeys); sort($expectedKeys);

View File

@@ -10,6 +10,7 @@
namespace App\Tests\API; namespace App\Tests\API;
use App\Entity\User; use App\Entity\User;
use Symfony\Component\HttpFoundation\Response;
/** /**
* @coversDefaultClass \App\API\CustomerController * @coversDefaultClass \App\API\CustomerController
@@ -62,15 +63,113 @@ class CustomerControllerTest extends APIControllerBaseTest
$this->assertEntityNotFound(User::ROLE_USER, '/api/customers/2'); $this->assertEntityNotFound(User::ROLE_USER, '/api/customers/2');
} }
public function testPostAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$data = [
'name' => 'foo',
'visible' => true,
'country' => 'DE',
'currency' => 'EUR',
'timezone' => 'Europe/Berlin',
];
$this->request($client, '/api/customers', 'POST', [], json_encode($data));
$this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result);
$this->assertStructure($result);
$this->assertNotEmpty($result['id']);
}
public function testPostActionWithInvalidUser()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$data = [
'name' => 'foo',
'visible' => true,
'country' => 'DE',
'currency' => 'EUR',
'timezone' => 'Europe/Berlin',
];
$this->request($client, '/api/customers', 'POST', [], json_encode($data));
$response = $client->getResponse();
$this->assertFalse($response->isSuccessful());
$this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode());
$json = json_decode($response->getContent(), true);
$this->assertEquals('User cannot create customers', $json['message']);
}
public function testPatchAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$data = [
'name' => 'foo',
'comment' => '',
'visible' => true,
'country' => 'DE',
'currency' => 'EUR',
'timezone' => 'Europe/Berlin',
];
$this->request($client, '/api/customers/1', 'PATCH', [], json_encode($data));
$this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result);
$this->assertStructure($result);
$this->assertNotEmpty($result['id']);
}
public function testPatchActionWithInvalidUser()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$data = [
'name' => 'foo',
'comment' => '',
'visible' => true,
'country' => 'DE',
'currency' => 'EUR',
'timezone' => 'Europe/Berlin',
];
$this->request($client, '/api/customers/1', 'PATCH', [], json_encode($data));
$response = $client->getResponse();
$this->assertFalse($response->isSuccessful());
$this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode());
$json = json_decode($response->getContent(), true);
$this->assertEquals('User cannot update customer', $json['message']);
}
public function testPatchActionWithUnknownActivity()
{
$this->assertEntityNotFoundForPatch(User::ROLE_USER, '/api/customers/255', []);
}
public function testInvalidPatchAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$data = [
'name' => 'foo',
'visible' => true,
'country' => 'DE',
'currency' => 'XXX',
'timezone' => 'Europe/Berlin',
];
$this->request($client, '/api/customers/1', 'PATCH', [], json_encode($data));
$response = $client->getResponse();
$this->assertEquals(400, $response->getStatusCode());
$this->assertApiCallValidationError($response, ['currency']);
}
protected function assertStructure(array $result, $full = true) protected function assertStructure(array $result, $full = true)
{ {
$expectedKeys = ['id', 'name', 'visible']; $expectedKeys = ['id', 'name', 'visible', 'hourlyRate', 'fixedRate'];
if ($full) { if ($full) {
$expectedKeys = [ $expectedKeys = array_merge($expectedKeys, [
'id', 'name', 'number', 'comment', 'visible', 'company', 'contact', 'address', 'country', 'currency', 'homepage', 'number', 'comment', 'company', 'contact', 'address', 'country', 'currency', 'phone', 'fax', 'mobile', 'email', 'timezone'
'phone', 'fax', 'mobile', 'email', 'timezone' ]);
];
} }
$actual = array_keys($result); $actual = array_keys($result);

View File

@@ -14,6 +14,7 @@ use App\Entity\Project;
use App\Entity\User; use App\Entity\User;
use App\Repository\Query\VisibilityQuery; use App\Repository\Query\VisibilityQuery;
use Symfony\Bundle\FrameworkBundle\Client; use Symfony\Bundle\FrameworkBundle\Client;
use Symfony\Component\HttpFoundation\Response;
/** /**
* @coversDefaultClass \App\API\ProjectController * @coversDefaultClass \App\API\ProjectController
@@ -120,16 +121,107 @@ class ProjectControllerTest extends APIControllerBaseTest
$this->assertEntityNotFound(User::ROLE_USER, '/api/projects/2'); $this->assertEntityNotFound(User::ROLE_USER, '/api/projects/2');
} }
public function testPostAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$data = [
'name' => 'foo',
'customer' => 1,
'visible' => true,
'budget' => 0,
];
$this->request($client, '/api/projects', 'POST', [], json_encode($data));
$this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result);
$this->assertStructure($result);
$this->assertNotEmpty($result['id']);
}
public function testPostActionWithInvalidUser()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$data = [
'name' => 'foo',
'customer' => 1,
'visible' => true
];
$this->request($client, '/api/projects', 'POST', [], json_encode($data));
$response = $client->getResponse();
$this->assertFalse($response->isSuccessful());
$this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode());
$json = json_decode($response->getContent(), true);
$this->assertEquals('User cannot create projects', $json['message']);
}
public function testPatchAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$data = [
'name' => 'foo',
'comment' => '',
'customer' => 1,
'visible' => true
];
$this->request($client, '/api/projects/1', 'PATCH', [], json_encode($data));
$this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result);
$this->assertStructure($result);
$this->assertNotEmpty($result['id']);
}
public function testPatchActionWithInvalidUser()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$data = [
'name' => 'foo',
'comment' => '',
'customer' => 1,
'visible' => true
];
$this->request($client, '/api/projects/1', 'PATCH', [], json_encode($data));
$response = $client->getResponse();
$this->assertFalse($response->isSuccessful());
$this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode());
$json = json_decode($response->getContent(), true);
$this->assertEquals('User cannot update project', $json['message']);
}
public function testPatchActionWithUnknownActivity()
{
$this->assertEntityNotFoundForPatch(User::ROLE_USER, '/api/projects/255', []);
}
public function testInvalidPatchAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$data = [
'name' => 'foo',
'customer' => 255,
'visible' => true
];
$this->request($client, '/api/projects/1', 'PATCH', [], json_encode($data));
$response = $client->getResponse();
$this->assertEquals(400, $response->getStatusCode());
$this->assertApiCallValidationError($response, ['customer']);
}
protected function assertStructure(array $result, $full = true) protected function assertStructure(array $result, $full = true)
{ {
$expectedKeys = [ $expectedKeys = [
'id', 'name', 'comment', 'visible', 'budget', 'order_number', 'customer' 'id', 'name', 'visible', 'customer', 'hourlyRate', 'fixedRate'
]; ];
if (!$full) { if ($full) {
$expectedKeys = [ $expectedKeys = array_merge(
'id', 'name', 'visible', 'customer' $expectedKeys,
]; ['comment', 'budget', 'orderNumber']
);
} }
$actual = array_keys($result); $actual = array_keys($result);

View File

@@ -13,10 +13,10 @@ use App\Constants;
use App\Entity\User; use App\Entity\User;
/** /**
* @coversDefaultClass \App\API\HealthcheckController * @coversDefaultClass \App\API\StatusController
* @group integration * @group integration
*/ */
class HealthcheckControllerTest extends APIControllerBaseTest class StatusControllerTest extends APIControllerBaseTest
{ {
public function testIsSecure() public function testIsSecure()
{ {

View File

@@ -24,7 +24,12 @@ class TimesheetControllerTest extends APIControllerBaseTest
{ {
public function setUp() public function setUp()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER);
}
protected function importFixtureForUser(string $role)
{
$client = $this->getClientForAuthenticatedUser($role);
$em = $client->getContainer()->get('doctrine.orm.entity_manager'); $em = $client->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new TimesheetFixtures(); $fixture = new TimesheetFixtures();
@@ -32,8 +37,8 @@ class TimesheetControllerTest extends APIControllerBaseTest
->setFixedRate(true) ->setFixedRate(true)
->setHourlyRate(true) ->setHourlyRate(true)
->setAmount(10) ->setAmount(10)
->setUser($this->getUserByRole($em, User::ROLE_USER)) ->setUser($this->getUserByRole($em, $role))
->setStartDate(new \DateTime('-10 days')) ->setStartDate((new \DateTime('-10 days'))->setTime(0, 0, 1))
->setAllowEmptyDescriptions(false) ->setAllowEmptyDescriptions(false)
; ;
$this->importFixture($em, $fixture); $this->importFixture($em, $fixture);
@@ -132,6 +137,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
'size' => 5, 'size' => 5,
'order' => 'DESC', 'order' => 'DESC',
'orderBy' => 'rate', 'orderBy' => 'rate',
'active' => 0,
'begin' => $begin->format('Y-m-d H:i:s'), 'begin' => $begin->format('Y-m-d H:i:s'),
'end' => $end->format('Y-m-d H:i:s'), 'end' => $end->format('Y-m-d H:i:s'),
'exported' => 0, 'exported' => 0,
@@ -224,6 +230,29 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertDefaultStructure($result); $this->assertDefaultStructure($result);
} }
public function testGetEntityAccessDenied()
{
$this->importFixtureForUser(User::ROLE_ADMIN);
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertApiAccessDenied($client, '/api/timesheets/15', 'You are not allowed to view this timesheet');
}
public function testGetEntityAccessAllowedForAdmin()
{
$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);
}
public function testGetEntityNotFound()
{
$this->assertEntityNotFound(User::ROLE_USER, '/api/timesheets/20');
}
public function testPostAction() public function testPostAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
@@ -265,8 +294,8 @@ class TimesheetControllerTest extends APIControllerBaseTest
$data = [ $data = [
'activity' => $activity->getId(), 'activity' => $activity->getId(),
'project' => $project->getId(), 'project' => $project->getId(),
'begin' => (new \DateTime('- 8 hours'))->format('Y-m-d H:m'), 'begin' => (new \DateTime('- 8 hours'))->format('Y-m-d H:m:s'),
'end' => (new \DateTime())->format('Y-m-d H:m'), 'end' => (new \DateTime())->format('Y-m-d H:m:s'),
'description' => 'foo', 'description' => 'foo',
'fixedRate' => 2016, 'fixedRate' => 2016,
'hourlyRate' => 127 'hourlyRate' => 127
@@ -303,19 +332,14 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertApiCallValidationError($client->getResponse(), ['activity']); $this->assertApiCallValidationError($client->getResponse(), ['activity']);
} }
public function testNotFound()
{
$this->assertEntityNotFound(User::ROLE_USER, '/api/timesheets/20');
}
public function testPatchAction() public function testPatchAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$data = [ $data = [
'activity' => 1, 'activity' => 1,
'project' => 1, 'project' => 1,
'begin' => (new \DateTime('- 7 hours'))->format('Y-m-d H:m'), 'begin' => (new \DateTime('- 7 hours'))->format('Y-m-d\TH:m'),
'end' => (new \DateTime())->format('Y-m-d H:m'), 'end' => (new \DateTime())->format('Y-m-d\TH:m'),
'description' => 'foo', 'description' => 'foo',
'exported' => true, 'exported' => true,
]; ];
@@ -349,8 +373,8 @@ class TimesheetControllerTest extends APIControllerBaseTest
$data = [ $data = [
'activity' => 1, 'activity' => 1,
'project' => 1, 'project' => 1,
'begin' => (new \DateTime('- 7 hours'))->format('Y-m-d H:m'), 'begin' => (new \DateTime('- 7 hours'))->format('Y-m-d\TH:m:s'),
'end' => (new \DateTime())->format('Y-m-d H:m'), 'end' => (new \DateTime())->format('Y-m-d\TH:m:s'),
'description' => 'foo', 'description' => 'foo',
'exported' => true, 'exported' => true,
]; ];
@@ -359,7 +383,12 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertFalse($response->isSuccessful()); $this->assertFalse($response->isSuccessful());
$this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode()); $this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode());
$json = json_decode($response->getContent(), true); $json = json_decode($response->getContent(), true);
$this->assertEquals('User cannot update timesheet', $json['message']); $this->assertEquals('You are not allowed to update this timesheet', $json['message']);
}
public function testPatchActionWithUnknownTimesheet()
{
$this->assertEntityNotFoundForPatch(User::ROLE_USER, '/api/timesheets/255', []);
} }
public function testInvalidPatchAction() public function testInvalidPatchAction()
@@ -387,7 +416,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
if ($full) { if ($full) {
$expectedKeys = array_merge($expectedKeys, [ $expectedKeys = array_merge($expectedKeys, [
'exported', 'fixed_rate', 'hourly_rate' 'exported', 'fixedRate', 'hourlyRate'
]); ]);
} }

View File

@@ -29,10 +29,40 @@ class UserControllerTest extends APIControllerBaseTest
$this->assertAccessIsGranted($client, '/api/users'); $this->assertAccessIsGranted($client, '/api/users');
$result = json_decode($client->getResponse()->getContent(), true); $result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result);
$this->assertNotEmpty($result);
$this->assertEquals(5, count($result));
foreach ($result as $user) {
$this->assertStructure($user, false);
}
}
public function testGetCollectionWithQuery()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/api/users', 'GET', ['visible' => 2, 'orderBy' => 'email', 'order' => 'DESC']);
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result);
$this->assertNotEmpty($result);
$this->assertEquals(1, count($result));
foreach ($result as $user) {
$this->assertStructure($user, false);
}
}
public function testGetCollectionWithQuery2()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/api/users', 'GET', ['visible' => 3, 'orderBy' => 'email', 'order' => 'DESC']);
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result); $this->assertIsArray($result);
$this->assertNotEmpty($result); $this->assertNotEmpty($result);
$this->assertEquals(6, count($result)); $this->assertEquals(6, count($result));
$this->assertStructure($result[0], false); foreach ($result as $user) {
$this->assertStructure($user, false);
}
} }
public function testGetEntity() public function testGetEntity()
@@ -50,12 +80,31 @@ class UserControllerTest extends APIControllerBaseTest
$this->assertEntityNotFound(User::ROLE_SUPER_ADMIN, '/api/users/99'); $this->assertEntityNotFound(User::ROLE_SUPER_ADMIN, '/api/users/99');
} }
public function testGetEntityAccessDenied()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertApiAccessDenied($client, '/api/users/4', 'You are not allowed to view this profile');
}
public function testGetEntityAccessAllowedForOwnProfile()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/users/2');
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result);
$this->assertStructure($result);
}
protected function assertStructure(array $result, $full = true) protected function assertStructure(array $result, $full = true)
{ {
$expectedKeys = ['id', 'username', 'enabled', 'alias']; $expectedKeys = ['id', 'username', 'enabled', 'alias'];
if ($full) { if ($full) {
$expectedKeys = ['id', 'username', 'enabled', 'roles', 'alias', 'title', 'avatar']; $expectedKeys = array_merge(
$expectedKeys,
['title', 'avatar', 'roles', 'language', 'timezone']
);
} }
$actual = array_keys($result); $actual = array_keys($result);

View File

@@ -29,9 +29,9 @@ class AboutControllerTest extends ControllerBaseTest
$this->assertAccessIsGranted($client, '/admin/about'); $this->assertAccessIsGranted($client, '/admin/about');
$result = $client->getCrawler()->filter('div.nav-tabs-custom ul.nav.nav-tabs li'); $result = $client->getCrawler()->filter('div.nav-tabs-custom ul.nav.nav-tabs li');
$this->assertEquals(2, count($result)); $this->assertEquals(3, count($result));
$result = $client->getCrawler()->filter('div.nav-tabs-custom div.tab-content div.tab-pane'); $result = $client->getCrawler()->filter('div.nav-tabs-custom div.tab-content div.tab-pane');
$this->assertEquals(2, count($result)); $this->assertEquals(3, count($result));
} }
} }

View File

@@ -93,6 +93,26 @@ class ActivityControllerTest extends ControllerBaseTest
} }
public function testEditAction() public function testEditAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/activity/1/edit');
$form = $client->getCrawler()->filter('form[name=activity_edit_form]')->form();
$this->assertFalse($form->has('activity_edit_form[create_more]'));
$this->assertEquals('Test', $form->get('activity_edit_form[name]')->getValue());
$client->submit($form, [
'activity_edit_form' => ['name' => 'Test 2', 'customer' => 1, 'project' => '1']
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/activity/'));
$client->followRedirect();
$this->assertHasDataTable($client);
$this->request($client, '/admin/activity/1/edit');
$editForm = $client->getCrawler()->filter('form[name=activity_edit_form]')->form();
$this->assertEquals('Test 2', $editForm->get('activity_edit_form[name]')->getValue());
$this->assertEquals('1', $editForm->get('activity_edit_form[customer]')->getValue());
$this->assertEquals('1', $editForm->get('activity_edit_form[project]')->getValue());
}
public function testEditActionForGlobalActivity()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/activity/1/edit'); $this->assertAccessIsGranted($client, '/admin/activity/1/edit');

View File

@@ -284,7 +284,7 @@ class TimesheetFixtures extends Fixture
private function createTimesheetEntry(User $user, Activity $activity, Project $project, $description, \DateTime $start, $setEndDate = true) private function createTimesheetEntry(User $user, Activity $activity, Project $project, $description, \DateTime $start, $setEndDate = true)
{ {
$end = clone $start; $end = clone $start;
$end = $end->modify('+ ' . (rand(1, 172800)) . ' seconds'); $end = $end->modify('+ ' . (rand(1, 86400)) . ' seconds');
$duration = $end->getTimestamp() - $start->getTimestamp(); $duration = $end->getTimestamp() - $start->getTimestamp();
$hourlyRate = (float) $user->getPreferenceValue(UserPreference::HOURLY_RATE); $hourlyRate = (float) $user->getPreferenceValue(UserPreference::HOURLY_RATE);

View File

@@ -18,6 +18,10 @@
<source>tab.license</source> <source>tab.license</source>
<target>Kimai Lizenz</target> <target>Kimai Lizenz</target>
</trans-unit> </trans-unit>
<trans-unit id="tab.thanks">
<source>tab.thanks</source>
<target>Danke!</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -18,6 +18,10 @@
<source>tab.license</source> <source>tab.license</source>
<target>Kimai License</target> <target>Kimai License</target>
</trans-unit> </trans-unit>
<trans-unit id="tab.thanks">
<source>tab.thanks</source>
<target>Special thanks</target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>