From 460391136f7aa79c5d2ede30bd3218083eab94e6 Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Wed, 24 Apr 2019 18:13:33 +0200 Subject: [PATCH] API: changed date-format, camelCase instead of snake_case, null values, update and create for customer and project (#718) --- .travis.yml | 1 - README.md | 2 +- UPGRADING.md | 18 +- composer.json | 6 +- composer.lock | 1164 ++++++++++++++--- config/packages/dev/jms_serializer.yaml | 2 +- config/packages/fos_rest.yaml | 6 +- config/packages/jms_serializer.yaml | 7 +- config/packages/nelmio_api_doc.yaml | 16 +- config/packages/prod/jms_serializer.yaml | 2 +- config/serializer/App/API.Model.Version.yml | 25 + config/serializer/App/Entity.Project.yml | 4 +- config/serializer/App/Entity.Timesheet.yml | 22 +- config/serializer/App/Entity.User.yml | 12 + .../serializer/FOS/UserBundle/Model.User.yml | 10 +- src/API/ActivityController.php | 61 +- src/API/BaseApiController.php | 3 + src/API/ConfigurationController.php | 8 +- src/API/CustomerController.php | 138 +- src/API/Model/I18n.php | 4 +- src/API/Model/Version.php | 38 + src/API/ProjectController.php | 136 +- ...eckController.php => StatusController.php} | 20 +- src/API/TimesheetController.php | 98 +- src/API/UserController.php | 64 +- src/Controller/ActivityController.php | 4 +- src/Controller/ProjectController.php | 15 +- src/Controller/TimesheetController.php | 2 + src/Controller/TimesheetTeamController.php | 2 + src/DataFixtures/TimesheetFixtures.php | 1 + src/DataFixtures/UserFixtures.php | 88 +- src/Form/ActivityEditForm.php | 42 +- src/Form/CustomerEditForm.php | 24 +- src/Form/ProjectEditForm.php | 30 +- src/Form/TimesheetEditForm.php | 77 +- src/Form/Type/ActivityType.php | 5 + src/Form/Type/CustomerType.php | 5 + src/Form/Type/DateTimePickerType.php | 6 + src/Form/Type/FixedRateType.php | 44 + src/Form/Type/HourlyRateType.php | 44 + src/Form/Type/ProjectType.php | 5 + src/Form/UserEditType.php | 2 - src/Repository/Query/BaseQuery.php | 2 +- src/Repository/UserRepository.php | 4 +- src/Voter/TimesheetVoter.php | 3 + symfony.lock | 39 + templates/about/system.html.twig | 19 + .../SwaggerUi/index.html.twig | 27 +- tests/API/APIControllerBaseTest.php | 41 + tests/API/ActivityControllerTest.php | 46 +- tests/API/ConfigurationControllerTest.php | 2 +- tests/API/CustomerControllerTest.php | 109 +- tests/API/ProjectControllerTest.php | 102 +- ...ollerTest.php => StatusControllerTest.php} | 4 +- tests/API/TimesheetControllerTest.php | 61 +- tests/API/UserControllerTest.php | 53 +- tests/Controller/AboutControllerTest.php | 4 +- tests/Controller/ActivityControllerTest.php | 20 + tests/DataFixtures/TimesheetFixtures.php | 2 +- translations/about.de.xliff | 4 + translations/about.en.xliff | 4 + 61 files changed, 2304 insertions(+), 505 deletions(-) create mode 100644 config/serializer/App/API.Model.Version.yml create mode 100644 src/API/Model/Version.php rename src/API/{HealthcheckController.php => StatusController.php} (71%) create mode 100644 src/Form/Type/FixedRateType.php create mode 100644 src/Form/Type/HourlyRateType.php rename tests/API/{HealthcheckControllerTest.php => StatusControllerTest.php} (94%) diff --git a/.travis.yml b/.travis.yml index a3906cdc..7e4bee04 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,7 +14,6 @@ services: matrix: fast_finish: true include: - - php: 7.1 - php: 7.2 - php: 7.3 diff --git a/README.md b/README.md index a729a96e..dfb373df 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Kimai is a [multi-language application](https://www.kimai.org/documentation/tran ### 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 - 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 diff --git a/UPGRADING.md b/UPGRADING.md index cbe1ee28..cdd02669 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -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)! -**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 -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) +### BC BREAKS + +This release contains some BC breaks, which were necessary before 1.0 will be released: "now or never" ;-) sorry for the troubles! + +- **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 -**Apply necessary changes to your `local.yaml`:** +### Apply necessary changes to your `local.yaml`: New permissions are available: - `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 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 - New permissions are available: diff --git a/composer.json b/composer.json index d9f2a0f3..089723f4 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ } ], "require": { - "php": "^7.1.3", + "php": "^7.2", "ext-gd": "*", "ext-intl": "*", "ext-mbstring": "*", @@ -23,7 +23,9 @@ "friendsofsymfony/user-bundle": "~2.0", "fzaninotto/faker": "^1.8", "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", "kimai/kimai2-composer": "^0.1", "mpdf/mpdf": "^7.1", diff --git a/composer.lock b/composer.lock index fcc88a3f..6e383f7d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ddfe61aa8547cda212eb651c6b57e526", + "content-hash": "6c37cabf2eb9324df6bc8603447c404d", "packages": [ { "name": "beberlei/DoctrineExtensions", @@ -1918,6 +1918,789 @@ ], "time": "2019-03-17T18:16:12+00:00" }, + { + "name": "hoa/compiler", + "version": "3.17.08.08", + "source": { + "type": "git", + "url": "https://github.com/hoaproject/Compiler.git", + "reference": "aa09caf0bf28adae6654ca6ee415ee2f522672de" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hoaproject/Compiler/zipball/aa09caf0bf28adae6654ca6ee415ee2f522672de", + "reference": "aa09caf0bf28adae6654ca6ee415ee2f522672de", + "shasum": "" + }, + "require": { + "hoa/consistency": "~1.0", + "hoa/exception": "~1.0", + "hoa/file": "~1.0", + "hoa/iterator": "~2.0", + "hoa/math": "~1.0", + "hoa/protocol": "~1.0", + "hoa/regex": "~1.0", + "hoa/visitor": "~2.0" + }, + "require-dev": { + "hoa/json": "~2.0", + "hoa/test": "~2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Hoa\\Compiler\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Ivan Enderlin", + "email": "ivan.enderlin@hoa-project.net" + }, + { + "name": "Hoa community", + "homepage": "https://hoa-project.net/" + } + ], + "description": "The Hoa\\Compiler library.", + "homepage": "https://hoa-project.net/", + "keywords": [ + "algebraic", + "ast", + "compiler", + "context-free", + "coverage", + "exhaustive", + "grammar", + "isotropic", + "language", + "lexer", + "library", + "ll1", + "llk", + "parser", + "pp", + "random", + "regular", + "rule", + "sampler", + "syntax", + "token", + "trace", + "uniform" + ], + "time": "2017-08-08T07:44:07+00:00" + }, + { + "name": "hoa/consistency", + "version": "1.17.05.02", + "source": { + "type": "git", + "url": "https://github.com/hoaproject/Consistency.git", + "reference": "fd7d0adc82410507f332516faf655b6ed22e4c2f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hoaproject/Consistency/zipball/fd7d0adc82410507f332516faf655b6ed22e4c2f", + "reference": "fd7d0adc82410507f332516faf655b6ed22e4c2f", + "shasum": "" + }, + "require": { + "hoa/exception": "~1.0", + "php": ">=5.5.0" + }, + "require-dev": { + "hoa/stream": "~1.0", + "hoa/test": "~2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Hoa\\Consistency\\": "." + }, + "files": [ + "Prelude.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Ivan Enderlin", + "email": "ivan.enderlin@hoa-project.net" + }, + { + "name": "Hoa community", + "homepage": "https://hoa-project.net/" + } + ], + "description": "The Hoa\\Consistency library.", + "homepage": "https://hoa-project.net/", + "keywords": [ + "autoloader", + "callable", + "consistency", + "entity", + "flex", + "keyword", + "library" + ], + "time": "2017-05-02T12:18:12+00:00" + }, + { + "name": "hoa/event", + "version": "1.17.01.13", + "source": { + "type": "git", + "url": "https://github.com/hoaproject/Event.git", + "reference": "6c0060dced212ffa3af0e34bb46624f990b29c54" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hoaproject/Event/zipball/6c0060dced212ffa3af0e34bb46624f990b29c54", + "reference": "6c0060dced212ffa3af0e34bb46624f990b29c54", + "shasum": "" + }, + "require": { + "hoa/consistency": "~1.0", + "hoa/exception": "~1.0" + }, + "require-dev": { + "hoa/test": "~2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Hoa\\Event\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Ivan Enderlin", + "email": "ivan.enderlin@hoa-project.net" + }, + { + "name": "Hoa community", + "homepage": "https://hoa-project.net/" + } + ], + "description": "The Hoa\\Event library.", + "homepage": "https://hoa-project.net/", + "keywords": [ + "event", + "library", + "listener", + "observer" + ], + "time": "2017-01-13T15:30:50+00:00" + }, + { + "name": "hoa/exception", + "version": "1.17.01.16", + "source": { + "type": "git", + "url": "https://github.com/hoaproject/Exception.git", + "reference": "091727d46420a3d7468ef0595651488bfc3a458f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hoaproject/Exception/zipball/091727d46420a3d7468ef0595651488bfc3a458f", + "reference": "091727d46420a3d7468ef0595651488bfc3a458f", + "shasum": "" + }, + "require": { + "hoa/consistency": "~1.0", + "hoa/event": "~1.0" + }, + "require-dev": { + "hoa/test": "~2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Hoa\\Exception\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Ivan Enderlin", + "email": "ivan.enderlin@hoa-project.net" + }, + { + "name": "Hoa community", + "homepage": "https://hoa-project.net/" + } + ], + "description": "The Hoa\\Exception library.", + "homepage": "https://hoa-project.net/", + "keywords": [ + "exception", + "library" + ], + "time": "2017-01-16T07:53:27+00:00" + }, + { + "name": "hoa/file", + "version": "1.17.07.11", + "source": { + "type": "git", + "url": "https://github.com/hoaproject/File.git", + "reference": "35cb979b779bc54918d2f9a4e02ed6c7a1fa67ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hoaproject/File/zipball/35cb979b779bc54918d2f9a4e02ed6c7a1fa67ca", + "reference": "35cb979b779bc54918d2f9a4e02ed6c7a1fa67ca", + "shasum": "" + }, + "require": { + "hoa/consistency": "~1.0", + "hoa/event": "~1.0", + "hoa/exception": "~1.0", + "hoa/iterator": "~2.0", + "hoa/stream": "~1.0" + }, + "require-dev": { + "hoa/test": "~2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Hoa\\File\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Ivan Enderlin", + "email": "ivan.enderlin@hoa-project.net" + }, + { + "name": "Hoa community", + "homepage": "https://hoa-project.net/" + } + ], + "description": "The Hoa\\File library.", + "homepage": "https://hoa-project.net/", + "keywords": [ + "Socket", + "directory", + "file", + "finder", + "library", + "link", + "temporary" + ], + "time": "2017-07-11T07:42:15+00:00" + }, + { + "name": "hoa/iterator", + "version": "2.17.01.10", + "source": { + "type": "git", + "url": "https://github.com/hoaproject/Iterator.git", + "reference": "d1120ba09cb4ccd049c86d10058ab94af245f0cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hoaproject/Iterator/zipball/d1120ba09cb4ccd049c86d10058ab94af245f0cc", + "reference": "d1120ba09cb4ccd049c86d10058ab94af245f0cc", + "shasum": "" + }, + "require": { + "hoa/consistency": "~1.0", + "hoa/exception": "~1.0" + }, + "require-dev": { + "hoa/test": "~2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Hoa\\Iterator\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Ivan Enderlin", + "email": "ivan.enderlin@hoa-project.net" + }, + { + "name": "Hoa community", + "homepage": "https://hoa-project.net/" + } + ], + "description": "The Hoa\\Iterator library.", + "homepage": "https://hoa-project.net/", + "keywords": [ + "iterator", + "library" + ], + "time": "2017-01-10T10:34:47+00:00" + }, + { + "name": "hoa/math", + "version": "1.17.05.16", + "source": { + "type": "git", + "url": "https://github.com/hoaproject/Math.git", + "reference": "7150785d30f5d565704912116a462e9f5bc83a0c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hoaproject/Math/zipball/7150785d30f5d565704912116a462e9f5bc83a0c", + "reference": "7150785d30f5d565704912116a462e9f5bc83a0c", + "shasum": "" + }, + "require": { + "hoa/compiler": "~3.0", + "hoa/consistency": "~1.0", + "hoa/exception": "~1.0", + "hoa/iterator": "~2.0", + "hoa/protocol": "~1.0", + "hoa/zformat": "~1.0" + }, + "require-dev": { + "hoa/test": "~2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Hoa\\Math\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Ivan Enderlin", + "email": "ivan.enderlin@hoa-project.net" + }, + { + "name": "Hoa community", + "homepage": "https://hoa-project.net/" + } + ], + "description": "The Hoa\\Math library.", + "homepage": "https://hoa-project.net/", + "keywords": [ + "arrangement", + "combination", + "combinatorics", + "counting", + "library", + "math", + "permutation", + "sampler", + "set" + ], + "time": "2017-05-16T08:02:17+00:00" + }, + { + "name": "hoa/protocol", + "version": "1.17.01.14", + "source": { + "type": "git", + "url": "https://github.com/hoaproject/Protocol.git", + "reference": "5c2cf972151c45f373230da170ea015deecf19e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hoaproject/Protocol/zipball/5c2cf972151c45f373230da170ea015deecf19e2", + "reference": "5c2cf972151c45f373230da170ea015deecf19e2", + "shasum": "" + }, + "require": { + "hoa/consistency": "~1.0", + "hoa/exception": "~1.0" + }, + "require-dev": { + "hoa/test": "~2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Hoa\\Protocol\\": "." + }, + "files": [ + "Wrapper.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Ivan Enderlin", + "email": "ivan.enderlin@hoa-project.net" + }, + { + "name": "Hoa community", + "homepage": "https://hoa-project.net/" + } + ], + "description": "The Hoa\\Protocol library.", + "homepage": "https://hoa-project.net/", + "keywords": [ + "library", + "protocol", + "resource", + "stream", + "wrapper" + ], + "time": "2017-01-14T12:26:10+00:00" + }, + { + "name": "hoa/regex", + "version": "1.17.01.13", + "source": { + "type": "git", + "url": "https://github.com/hoaproject/Regex.git", + "reference": "7e263a61b6fb45c1d03d8e5ef77668518abd5bec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hoaproject/Regex/zipball/7e263a61b6fb45c1d03d8e5ef77668518abd5bec", + "reference": "7e263a61b6fb45c1d03d8e5ef77668518abd5bec", + "shasum": "" + }, + "require": { + "hoa/consistency": "~1.0", + "hoa/exception": "~1.0", + "hoa/math": "~1.0", + "hoa/protocol": "~1.0", + "hoa/ustring": "~4.0", + "hoa/visitor": "~2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Hoa\\Regex\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Ivan Enderlin", + "email": "ivan.enderlin@hoa-project.net" + }, + { + "name": "Hoa community", + "homepage": "https://hoa-project.net/" + } + ], + "description": "The Hoa\\Regex library.", + "homepage": "https://hoa-project.net/", + "keywords": [ + "compiler", + "library", + "regex" + ], + "time": "2017-01-13T16:10:24+00:00" + }, + { + "name": "hoa/stream", + "version": "1.17.02.21", + "source": { + "type": "git", + "url": "https://github.com/hoaproject/Stream.git", + "reference": "3293cfffca2de10525df51436adf88a559151d82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hoaproject/Stream/zipball/3293cfffca2de10525df51436adf88a559151d82", + "reference": "3293cfffca2de10525df51436adf88a559151d82", + "shasum": "" + }, + "require": { + "hoa/consistency": "~1.0", + "hoa/event": "~1.0", + "hoa/exception": "~1.0", + "hoa/protocol": "~1.0" + }, + "require-dev": { + "hoa/test": "~2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Hoa\\Stream\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Ivan Enderlin", + "email": "ivan.enderlin@hoa-project.net" + }, + { + "name": "Hoa community", + "homepage": "https://hoa-project.net/" + } + ], + "description": "The Hoa\\Stream library.", + "homepage": "https://hoa-project.net/", + "keywords": [ + "Context", + "bucket", + "composite", + "filter", + "in", + "library", + "out", + "protocol", + "stream", + "wrapper" + ], + "time": "2017-02-21T16:01:06+00:00" + }, + { + "name": "hoa/ustring", + "version": "4.17.01.16", + "source": { + "type": "git", + "url": "https://github.com/hoaproject/Ustring.git", + "reference": "e6326e2739178799b1fe3fdd92029f9517fa17a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hoaproject/Ustring/zipball/e6326e2739178799b1fe3fdd92029f9517fa17a0", + "reference": "e6326e2739178799b1fe3fdd92029f9517fa17a0", + "shasum": "" + }, + "require": { + "hoa/consistency": "~1.0", + "hoa/exception": "~1.0" + }, + "require-dev": { + "hoa/test": "~2.0" + }, + "suggest": { + "ext-iconv": "ext/iconv must be present (or a third implementation) to use Hoa\\Ustring::transcode().", + "ext-intl": "To get a better Hoa\\Ustring::toAscii() and Hoa\\Ustring::compareTo()." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "psr-4": { + "Hoa\\Ustring\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Ivan Enderlin", + "email": "ivan.enderlin@hoa-project.net" + }, + { + "name": "Hoa community", + "homepage": "https://hoa-project.net/" + } + ], + "description": "The Hoa\\Ustring library.", + "homepage": "https://hoa-project.net/", + "keywords": [ + "library", + "search", + "string", + "unicode" + ], + "time": "2017-01-16T07:08:25+00:00" + }, + { + "name": "hoa/visitor", + "version": "2.17.01.16", + "source": { + "type": "git", + "url": "https://github.com/hoaproject/Visitor.git", + "reference": "c18fe1cbac98ae449e0d56e87469103ba08f224a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hoaproject/Visitor/zipball/c18fe1cbac98ae449e0d56e87469103ba08f224a", + "reference": "c18fe1cbac98ae449e0d56e87469103ba08f224a", + "shasum": "" + }, + "require": { + "hoa/consistency": "~1.0" + }, + "require-dev": { + "hoa/test": "~2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Hoa\\Visitor\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Ivan Enderlin", + "email": "ivan.enderlin@hoa-project.net" + }, + { + "name": "Hoa community", + "homepage": "https://hoa-project.net/" + } + ], + "description": "The Hoa\\Visitor library.", + "homepage": "https://hoa-project.net/", + "keywords": [ + "library", + "structure", + "visit", + "visitor" + ], + "time": "2017-01-16T07:02:03+00:00" + }, + { + "name": "hoa/zformat", + "version": "1.17.01.10", + "source": { + "type": "git", + "url": "https://github.com/hoaproject/Zformat.git", + "reference": "522c381a2a075d4b9dbb42eb4592dd09520e4ac2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hoaproject/Zformat/zipball/522c381a2a075d4b9dbb42eb4592dd09520e4ac2", + "reference": "522c381a2a075d4b9dbb42eb4592dd09520e4ac2", + "shasum": "" + }, + "require": { + "hoa/consistency": "~1.0", + "hoa/exception": "~1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Hoa\\Zformat\\": "." + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Ivan Enderlin", + "email": "ivan.enderlin@hoa-project.net" + }, + { + "name": "Hoa community", + "homepage": "https://hoa-project.net/" + } + ], + "description": "The Hoa\\Zformat library.", + "homepage": "https://hoa-project.net/", + "keywords": [ + "library", + "parameter", + "zformat" + ], + "time": "2017-01-10T10:39:54+00:00" + }, { "name": "jdorn/sql-formatter", "version": "v1.2.17", @@ -1970,33 +2753,35 @@ }, { "name": "jms/metadata", - "version": "1.7.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/schmittjoh/metadata.git", - "reference": "e5854ab1aa643623dc64adde718a8eec32b957a8" + "reference": "e918c3a65105f73b74d94a0837b9f7d611d5bf0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/metadata/zipball/e5854ab1aa643623dc64adde718a8eec32b957a8", - "reference": "e5854ab1aa643623dc64adde718a8eec32b957a8", + "url": "https://api.github.com/repos/schmittjoh/metadata/zipball/e918c3a65105f73b74d94a0837b9f7d611d5bf0c", + "reference": "e918c3a65105f73b74d94a0837b9f7d611d5bf0c", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": "^7.2" }, "require-dev": { - "doctrine/cache": "~1.0", - "symfony/cache": "~3.1" + "doctrine/cache": "^1.0", + "doctrine/coding-standard": "^4.0", + "phpunit/phpunit": "^7.0", + "symfony/cache": "^3.1|^4.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.5.x-dev" + "dev-master": "2.x-dev" } }, "autoload": { - "psr-0": { + "psr-4": { "Metadata\\": "src/" } }, @@ -2021,100 +2806,65 @@ "xml", "yaml" ], - "time": "2018-10-26T12:40:10+00:00" - }, - { - "name": "jms/parser-lib", - "version": "1.0.0", - "source": { - "type": "git", - "url": "https://github.com/schmittjoh/parser-lib.git", - "reference": "c509473bc1b4866415627af0e1c6cc8ac97fa51d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/parser-lib/zipball/c509473bc1b4866415627af0e1c6cc8ac97fa51d", - "reference": "c509473bc1b4866415627af0e1c6cc8ac97fa51d", - "shasum": "" - }, - "require": { - "phpoption/phpoption": ">=0.9,<2.0-dev" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, - "autoload": { - "psr-0": { - "JMS\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache2" - ], - "description": "A library for easily creating recursive-descent parsers.", - "time": "2012-11-18T18:08:43+00:00" + "time": "2018-11-09T13:57:43+00:00" }, { "name": "jms/serializer", - "version": "1.14.0", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/schmittjoh/serializer.git", - "reference": "ee96d57024af9a7716d56fcbe3aa94b3d030f3ca" + "reference": "beb3f1805c7d22c9be5ff91e0dc7e5a49a8b08ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/serializer/zipball/ee96d57024af9a7716d56fcbe3aa94b3d030f3ca", - "reference": "ee96d57024af9a7716d56fcbe3aa94b3d030f3ca", + "url": "https://api.github.com/repos/schmittjoh/serializer/zipball/beb3f1805c7d22c9be5ff91e0dc7e5a49a8b08ad", + "reference": "beb3f1805c7d22c9be5ff91e0dc7e5a49a8b08ad", "shasum": "" }, "require": { "doctrine/annotations": "^1.0", "doctrine/instantiator": "^1.0.3", - "jms/metadata": "^1.3", - "jms/parser-lib": "1.*", - "php": "^5.5|^7.0", - "phpcollection/phpcollection": "~0.1", - "phpoption/phpoption": "^1.1" + "hoa/compiler": "^3.17.08.08", + "jms/metadata": "^2.0", + "php": "^7.2" }, "conflict": { - "twig/twig": "<1.12" + "hoa/consistency": "<1.17.05.02", + "hoa/core": "*", + "hoa/iterator": "<2.16.03.15" }, "require-dev": { + "doctrine/coding-standard": "^5.0", "doctrine/orm": "~2.1", "doctrine/phpcr-odm": "^1.3|^2.0", "ext-pdo_sqlite": "*", "jackalope/jackalope-doctrine-dbal": "^1.1.5", - "phpunit/phpunit": "^4.8|^5.0", - "propel/propel1": "~1.7", + "phpunit/phpunit": "^7.1", "psr/container": "^1.0", - "symfony/dependency-injection": "^2.7|^3.3|^4.0", - "symfony/expression-language": "^2.6|^3.0", - "symfony/filesystem": "^2.1", - "symfony/form": "~2.1|^3.0", - "symfony/translation": "^2.1|^3.0", - "symfony/validator": "^2.2|^3.0", - "symfony/yaml": "^2.1|^3.0", - "twig/twig": "~1.12|~2.0" + "symfony/dependency-injection": "^3.0|^4.0", + "symfony/expression-language": "^3.0|^4.0", + "symfony/filesystem": "^3.0|^4.0", + "symfony/form": "^3.0|^4.0", + "symfony/translation": "^3.0|^4.0", + "symfony/validator": "^3.1.9|^4.0", + "symfony/yaml": "^3.3|^4.0", + "twig/twig": "^1.12|^2.0" }, "suggest": { "doctrine/cache": "Required if you like to use cache functionality.", "doctrine/collections": "Required if you like to use doctrine collection types as ArrayCollection.", - "symfony/yaml": "Required if you'd like to serialize data to YAML format." + "symfony/yaml": "Required if you'd like to use the YAML metadata format." }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.14-dev" + "dev-master": "2.3-dev" } }, "autoload": { - "psr-0": { - "JMS\\Serializer": "src/" + "psr-4": { + "JMS\\Serializer\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2140,47 +2890,47 @@ "serialization", "xml" ], - "time": "2019-04-17T08:12:16+00:00" + "time": "2019-04-17T08:03:38+00:00" }, { "name": "jms/serializer-bundle", - "version": "2.4.4", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/schmittjoh/JMSSerializerBundle.git", - "reference": "92ee808c64c1c180775a0e57d00e3be0674668fb" + "reference": "0ab81ca1abc4e843dc5d8eea9b79b13721f4a92f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/JMSSerializerBundle/zipball/92ee808c64c1c180775a0e57d00e3be0674668fb", - "reference": "92ee808c64c1c180775a0e57d00e3be0674668fb", + "url": "https://api.github.com/repos/schmittjoh/JMSSerializerBundle/zipball/0ab81ca1abc4e843dc5d8eea9b79b13721f4a92f", + "reference": "0ab81ca1abc4e843dc5d8eea9b79b13721f4a92f", "shasum": "" }, "require": { - "jms/serializer": "^1.10", - "php": "^5.4|^7.0", - "phpoption/phpoption": "^1.1.0", - "symfony/framework-bundle": "~2.3|~3.0|~4.0" + "jms/serializer": "^2.2", + "php": "^7.2", + "symfony/dependency-injection": "^3.3 || ^4.0", + "symfony/framework-bundle": "^3.0 || ^4.0" }, "require-dev": { - "doctrine/orm": "*", - "phpunit/phpunit": "^4.8.35|^5.4.3|^6.0", - "symfony/expression-language": "~2.6|~3.0|~4.0", - "symfony/finder": "^2.3|^3.0|^4.0", - "symfony/form": "*", - "symfony/stopwatch": "*", + "doctrine/orm": "^2.4", + "phpunit/phpunit": "^6.0", + "symfony/expression-language": "^3.0 || ^4.0", + "symfony/finder": "^3.0 || ^4.0", + "symfony/form": "^3.0 || ^4.0", + "symfony/stopwatch": "^3.0 || ^4.0", "symfony/twig-bundle": "*", - "symfony/validator": "*", - "symfony/yaml": "*" + "symfony/validator": "^3.0 || ^4.0", + "symfony/yaml": "^3.0 || ^4.0" }, "suggest": { - "jms/di-extra-bundle": "Required to get lazy loading (de)serialization visitors, ~1.3", - "symfony/finder": "Required for cache warmup, supported versions ^2.3|^3.0|^4.0" + "jms/di-extra-bundle": "Required to get lazy loading (de)serialization visitors, ^1.3", + "symfony/finder": "Required for cache warmup, supported versions ^3.0|^4.0" }, "type": "symfony-bundle", "extra": { "branch-alias": { - "dev-master": "2.4-dev" + "dev-master": "3.2-dev" } }, "autoload": { @@ -2209,12 +2959,11 @@ "homepage": "http://jmsyst.com/bundles/JMSSerializerBundle", "keywords": [ "deserialization", - "jaxb", "json", "serialization", "xml" ], - "time": "2019-03-30T10:26:09+00:00" + "time": "2019-03-21T06:43:47+00:00" }, { "name": "kevinpapst/adminlte-bundle", @@ -3154,54 +3903,6 @@ ], "time": "2014-06-05T11:42:24+00:00" }, - { - "name": "phpcollection/phpcollection", - "version": "0.5.0", - "source": { - "type": "git", - "url": "https://github.com/schmittjoh/php-collection.git", - "reference": "f2bcff45c0da7c27991bbc1f90f47c4b7fb434a6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-collection/zipball/f2bcff45c0da7c27991bbc1f90f47c4b7fb434a6", - "reference": "f2bcff45c0da7c27991bbc1f90f47c4b7fb434a6", - "shasum": "" - }, - "require": { - "phpoption/phpoption": "1.*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "0.4-dev" - } - }, - "autoload": { - "psr-0": { - "PhpCollection": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache2" - ], - "authors": [ - { - "name": "Johannes M. Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "General-Purpose Collection Library for PHP", - "keywords": [ - "collection", - "list", - "map", - "sequence", - "set" - ], - "time": "2015-05-17T12:39:23+00:00" - }, { "name": "phpdocumentor/reflection-common", "version": "1.0.1", @@ -3612,56 +4313,6 @@ ], "time": "2018-07-14T16:59:43+00:00" }, - { - "name": "phpoption/phpoption", - "version": "1.5.0", - "source": { - "type": "git", - "url": "https://github.com/schmittjoh/php-option.git", - "reference": "94e644f7d2051a5f0fcf77d81605f152eecff0ed" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/94e644f7d2051a5f0fcf77d81605f152eecff0ed", - "reference": "94e644f7d2051a5f0fcf77d81605f152eecff0ed", - "shasum": "" - }, - "require": { - "php": ">=5.3.0" - }, - "require-dev": { - "phpunit/phpunit": "4.7.*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.3-dev" - } - }, - "autoload": { - "psr-0": { - "PhpOption\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache2" - ], - "authors": [ - { - "name": "Johannes M. Schmitt", - "email": "schmittjoh@gmail.com" - } - ], - "description": "Option Type for PHP", - "keywords": [ - "language", - "option", - "php", - "type" - ], - "time": "2015-07-25T16:39:46+00:00" - }, { "name": "psr/cache", "version": "1.0.1", @@ -8373,6 +9024,41 @@ "description": "A tool to automatically fix PHP code style", "time": "2019-02-17T17:44:13+00:00" }, + { + "name": "jms/parser-lib", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/parser-lib.git", + "reference": "c509473bc1b4866415627af0e1c6cc8ac97fa51d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/parser-lib/zipball/c509473bc1b4866415627af0e1c6cc8ac97fa51d", + "reference": "c509473bc1b4866415627af0e1c6cc8ac97fa51d", + "shasum": "" + }, + "require": { + "phpoption/phpoption": ">=0.9,<2.0-dev" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-0": { + "JMS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache2" + ], + "description": "A library for easily creating recursive-descent parsers.", + "time": "2012-11-18T18:08:43+00:00" + }, { "name": "phar-io/manifest", "version": "1.0.3", @@ -8526,6 +9212,104 @@ ], "time": "2018-02-15T16:58:55+00:00" }, + { + "name": "phpcollection/phpcollection", + "version": "0.5.0", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-collection.git", + "reference": "f2bcff45c0da7c27991bbc1f90f47c4b7fb434a6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-collection/zipball/f2bcff45c0da7c27991bbc1f90f47c4b7fb434a6", + "reference": "f2bcff45c0da7c27991bbc1f90f47c4b7fb434a6", + "shasum": "" + }, + "require": { + "phpoption/phpoption": "1.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.4-dev" + } + }, + "autoload": { + "psr-0": { + "PhpCollection": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache2" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "General-Purpose Collection Library for PHP", + "keywords": [ + "collection", + "list", + "map", + "sequence", + "set" + ], + "time": "2015-05-17T12:39:23+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "94e644f7d2051a5f0fcf77d81605f152eecff0ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/94e644f7d2051a5f0fcf77d81605f152eecff0ed", + "reference": "94e644f7d2051a5f0fcf77d81605f152eecff0ed", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "4.7.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-0": { + "PhpOption\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache2" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "time": "2015-07-25T16:39:46+00:00" + }, { "name": "phpspec/prophecy", "version": "1.8.0", diff --git a/config/packages/dev/jms_serializer.yaml b/config/packages/dev/jms_serializer.yaml index 353e4602..f9460410 100644 --- a/config/packages/dev/jms_serializer.yaml +++ b/config/packages/dev/jms_serializer.yaml @@ -1,6 +1,6 @@ jms_serializer: visitors: - json: + json_serialization: options: - JSON_PRETTY_PRINT - JSON_UNESCAPED_SLASHES diff --git a/config/packages/fos_rest.yaml b/config/packages/fos_rest.yaml index aedd722b..915d573b 100644 --- a/config/packages/fos_rest.yaml +++ b/config/packages/fos_rest.yaml @@ -29,10 +29,10 @@ fos_rest: # view_handler: fos_rest.view_handler.default # inflector: fos_rest.inflector.doctrine # validator: validator -# serializer: + serializer: # version: null # groups: [] -# serialize_null: false + serialize_null: true view: default_engine: twig # force_redirects: @@ -75,7 +75,7 @@ fos_rest: # decoders: # name: ~ array_normalizer: - service: null + service: fos_rest.normalizer.camel_keys forms: true format_listener: enabled: true diff --git a/config/packages/jms_serializer.yaml b/config/packages/jms_serializer.yaml index 33abf699..fe0f7f3e 100644 --- a/config/packages/jms_serializer.yaml +++ b/config/packages/jms_serializer.yaml @@ -1,6 +1,9 @@ jms_serializer: + handlers: + datetime: + default_format: 'Y-m-d\TH:i:sO' # DATE_ISO8601 visitors: - xml: + xml_serialization: format_output: '%kernel.debug%' metadata: directories: @@ -10,3 +13,5 @@ jms_serializer: App: namespace_prefix: "App" path: "%kernel.root_dir%/../config/serializer/App" + property_naming: + id: 'jms_serializer.identical_property_naming_strategy' diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml index 14bc2b4c..798823b2 100644 --- a/config/packages/nelmio_api_doc.yaml +++ b/config/packages/nelmio_api_doc.yaml @@ -2,13 +2,20 @@ nelmio_api_doc: models: use_jms: true names: + - { alias: CustomerEditForm, type: App\Form\CustomerEditForm, 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: 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: 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: 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: UserCollection, type: App\Entity\User, groups: [Default, Collection, User] } - { alias: I18nConfig, type: App\API\Model\I18n, groups: [Default] } areas: path_patterns: @@ -18,8 +25,11 @@ nelmio_api_doc: schemes: [http, https] info: 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! - version: 0.2 + description: | + 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: # hostname: # name: hostname diff --git a/config/packages/prod/jms_serializer.yaml b/config/packages/prod/jms_serializer.yaml index bc97faf1..89c86c89 100644 --- a/config/packages/prod/jms_serializer.yaml +++ b/config/packages/prod/jms_serializer.yaml @@ -1,6 +1,6 @@ jms_serializer: visitors: - json: + json_serialization: options: - JSON_UNESCAPED_SLASHES - JSON_PRESERVE_ZERO_FRACTION diff --git a/config/serializer/App/API.Model.Version.yml b/config/serializer/App/API.Model.Version.yml new file mode 100644 index 00000000..393389a8 --- /dev/null +++ b/config/serializer/App/API.Model.Version.yml @@ -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] diff --git a/config/serializer/App/Entity.Project.yml b/config/serializer/App/Entity.Project.yml index 2f36716f..1c81348d 100644 --- a/config/serializer/App/Entity.Project.yml +++ b/config/serializer/App/Entity.Project.yml @@ -22,10 +22,10 @@ App\Entity\Project: groups: [Entity] fixedRate: include: true - groups: [Entity] + groups: [Default] hourlyRate: include: true - groups: [Entity] + groups: [Default] customer: include: false exclude: true diff --git a/config/serializer/App/Entity.Timesheet.yml b/config/serializer/App/Entity.Timesheet.yml index 6907c7fb..72663941 100644 --- a/config/serializer/App/Entity.Timesheet.yml +++ b/config/serializer/App/Entity.Timesheet.yml @@ -6,11 +6,9 @@ App\Entity\Timesheet: include: true groups: [Default] begin: - include: true - groups: [Default] + exclude: true end: - include: true - groups: [Default] + exclude: true duration: include: true groups: [Default] @@ -30,18 +28,22 @@ App\Entity\Timesheet: include: true groups: [Entity] activity: - include: false exclude: true - groups: [Default] project: - include: false exclude: true - groups: [Default] user: - include: false exclude: true - groups: [Default] 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: serialized_name: activity exp: "object.getActivity() === null ? null : object.getActivity().getId()" diff --git a/config/serializer/App/Entity.User.yml b/config/serializer/App/Entity.User.yml index 4fe7a477..8907f719 100644 --- a/config/serializer/App/Entity.User.yml +++ b/config/serializer/App/Entity.User.yml @@ -1,5 +1,6 @@ App\Entity\User: exclusion_policy: All + custom_accessor_order: [id, alias, title, avatar, language, timezone] properties: id: include: true @@ -13,3 +14,14 @@ App\Entity\User: avatar: include: true 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] diff --git a/config/serializer/FOS/UserBundle/Model.User.yml b/config/serializer/FOS/UserBundle/Model.User.yml index 2ca9689a..6b28923f 100644 --- a/config/serializer/FOS/UserBundle/Model.User.yml +++ b/config/serializer/FOS/UserBundle/Model.User.yml @@ -1,16 +1,18 @@ FOS\UserBundle\Model\User: - exclusion_policy: NONE + exclusion_policy: All properties: + username: + include: true + groups: [Default] enabled: include: true groups: [Default] - groups: - include: true - groups: [Entity] roles: type: array include: true groups: [Entity] + groups: + exclude: true email: exclude: true emailCanonical: diff --git a/src/API/ActivityController.php b/src/API/ActivityController.php index 1471bee4..bd6bf141 100644 --- a/src/API/ActivityController.php +++ b/src/API/ActivityController.php @@ -24,6 +24,7 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Swagger\Annotations as SWG; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** * @RouteResource("Activity") @@ -53,20 +54,22 @@ class ActivityController extends BaseApiController } /** + * Returns a collection of activities + * * @SWG\Response( * response=200, - * description="Returns the collection of all existing activities", + * description="Returns a collection of activity entities", * @SWG\Schema( * 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="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter activities (1=visible, 2=hidden, 3=both)") - * @Rest\QueryParam(name="globals", requirements="true", strict=true, nullable=true, description="Pass 'true' as string to fetch only global activities") - * @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="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')") + * @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="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="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="Use if you don't want global activities to be listed first. Allowed values: false (default: true)") + * @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="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)") * * @return Response */ @@ -110,11 +113,20 @@ class ActivityController extends BaseApiController } /** + * Returns one activity + * * @SWG\Response( * response=200, * description="Returns one activity entity", * @SWG\Schema(ref="#/definitions/ActivityEntity"), * ) + * @SWG\Parameter( + * name="id", + * in="path", + * type="integer", + * description="Activity ID to fetch", + * required=true, + * ) * * @param int $id * @return Response @@ -122,9 +134,11 @@ class ActivityController extends BaseApiController public function getAction($id) { $data = $this->repository->find($id); + if (null === $data) { throw new NotFoundException(); } + $view = new View($data, 200); $view->getContext()->setGroups(['Default', 'Entity', 'Activity']); @@ -132,11 +146,13 @@ class ActivityController extends BaseApiController } /** + * Creates a new activity + * * @SWG\Post( - * description="Creates a new activity entry and returns it afterwards", + * description="Creates a new activity and returns it afterwards", * @SWG\Response( * response=200, - * description="Returns the new created activity entry", + * description="Returns the new created activity", * @SWG\Schema(ref="#/definitions/ActivityEntity"), * ) * ) @@ -156,7 +172,7 @@ class ActivityController extends BaseApiController public function postAction(Request $request) { if (!$this->isGranted('create_activity')) { - throw $this->createAccessDeniedException('User cannot create activities'); + throw new AccessDeniedHttpException('User cannot create activities'); } $activity = new Activity(); @@ -168,10 +184,6 @@ class ActivityController extends BaseApiController $form->submit($request->request->all()); 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->persist($activity); $entityManager->flush(); @@ -189,11 +201,13 @@ class ActivityController extends BaseApiController } /** + * Update an existing activity + * * @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( * response=200, - * description="Returns the updated activity entry", + * description="Returns the updated activity", * @SWG\Schema(ref="#/definitions/ActivityEntity") * ) * ) @@ -203,6 +217,13 @@ class ActivityController extends BaseApiController * required=true, * @SWG\Schema(ref="#/definitions/ActivityEditForm") * ) + * @SWG\Parameter( + * name="id", + * in="path", + * type="integer", + * description="Activity ID to update", + * required=true, + * ) * * @param Request $request * @param string $id @@ -212,8 +233,12 @@ class ActivityController extends BaseApiController { $activity = $this->repository->find($id); + if (null === $activity) { + throw new NotFoundException(); + } + 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, [ diff --git a/src/API/BaseApiController.php b/src/API/BaseApiController.php index e9cdf737..03afd668 100644 --- a/src/API/BaseApiController.php +++ b/src/API/BaseApiController.php @@ -12,7 +12,10 @@ declare(strict_types=1); namespace App\API; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\Extension\Core\Type\DateTimeType; abstract class BaseApiController extends AbstractController { + public const DATE_FORMAT = DateTimeType::HTML5_FORMAT; + public const DATE_FORMAT_PHP = 'Y-m-d\TH:m:s'; } diff --git a/src/API/ConfigurationController.php b/src/API/ConfigurationController.php index d14aeb62..91efe8d1 100644 --- a/src/API/ConfigurationController.php +++ b/src/API/ConfigurationController.php @@ -17,8 +17,12 @@ use App\Entity\User; use FOS\RestBundle\Controller\Annotations as Rest; use FOS\RestBundle\View\View; use FOS\RestBundle\View\ViewHandlerInterface; +use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Swagger\Annotations as SWG; +/** + * @Security("is_granted('ROLE_USER')") + */ class ConfigurationController extends BaseApiController { /** @@ -41,7 +45,9 @@ class ConfigurationController extends BaseApiController } /** - * @SWG\Response( + * Returns the user specific locale configuration + * + * @SWG\Response( * response=200, * description="Returns the locale specific configurations for this user", * @SWG\Schema(ref="#/definitions/I18nConfig") diff --git a/src/API/CustomerController.php b/src/API/CustomerController.php index aea059fa..ae767396 100644 --- a/src/API/CustomerController.php +++ b/src/API/CustomerController.php @@ -11,6 +11,8 @@ declare(strict_types=1); namespace App\API; +use App\Entity\Customer; +use App\Form\CustomerEditForm; use App\Repository\CustomerRepository; use App\Repository\Query\CustomerQuery; use FOS\RestBundle\Controller\Annotations as Rest; @@ -20,7 +22,9 @@ use FOS\RestBundle\View\View; use FOS\RestBundle\View\ViewHandlerInterface; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Swagger\Annotations as SWG; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** * @RouteResource("Customer") @@ -50,17 +54,19 @@ class CustomerController extends BaseApiController } /** + * Returns a collection of customers + * * @SWG\Response( * response=200, - * description="Returns the collection of all existing customer", + * description="Returns a collection of customer entities", * @SWG\Schema( * 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="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order (allowed values: 'ASC', 'DESC')") - * @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="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 (default: name)") * * @return Response */ @@ -92,6 +98,8 @@ class CustomerController extends BaseApiController } /** + * Returns one customer + * * @SWG\Response( * response=200, * description="Returns one customer entity", @@ -104,12 +112,134 @@ class CustomerController extends BaseApiController public function getAction($id) { $data = $this->repository->find($id); + if (null === $data) { throw new NotFoundException(); } + $view = new View($data, 200); $view->getContext()->setGroups(['Default', 'Entity', 'Customer']); 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); + } } diff --git a/src/API/Model/I18n.php b/src/API/Model/I18n.php index 4ddfd63d..43ea485e 100644 --- a/src/API/Model/I18n.php +++ b/src/API/Model/I18n.php @@ -14,13 +14,13 @@ namespace App\API\Model; class I18n { /** - * Format used for 'begin' and 'end' in TimesheetEditForm: POST, PATCH + * Format used for 'begin' and 'end' * * @var string */ protected $formDateTime = ''; /** - * Format used for Timesheet queries in: GET + * Format used for toolbar queries * * @var string */ diff --git a/src/API/Model/Version.php b/src/API/Model/Version.php new file mode 100644 index 00000000..6c43d512 --- /dev/null +++ b/src/API/Model/Version.php @@ -0,0 +1,38 @@ +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); + } } diff --git a/src/API/HealthcheckController.php b/src/API/StatusController.php similarity index 71% rename from src/API/HealthcheckController.php rename to src/API/StatusController.php index 997b8b9b..59e8c27f 100644 --- a/src/API/HealthcheckController.php +++ b/src/API/StatusController.php @@ -11,13 +11,14 @@ declare(strict_types=1); namespace App\API; -use App\Constants; +use App\API\Model\Version; use FOS\RestBundle\Controller\Annotations as Rest; use FOS\RestBundle\View\View; use FOS\RestBundle\View\ViewHandlerInterface; +use Nelmio\ApiDocBundle\Annotation\Model; use Swagger\Annotations as SWG; -class HealthcheckController extends BaseApiController +class StatusController extends BaseApiController { /** * @var ViewHandlerInterface @@ -33,6 +34,8 @@ class HealthcheckController extends BaseApiController } /** + * A testing route for the API + * * @SWG\Response( * response=200, * 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( * response=200, * description="Returns version information about the current release", + * @SWG\Schema(ref=@Model(type=Version::class)) * ) * * @Rest\Get(path="/version") */ public function versionAction() { - $version = [ - '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)); + return $this->viewHandler->handle(new View(new Version(), 200)); } } diff --git a/src/API/TimesheetController.php b/src/API/TimesheetController.php index 7cc44e77..68e9ef87 100644 --- a/src/API/TimesheetController.php +++ b/src/API/TimesheetController.php @@ -27,10 +27,13 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Swagger\Annotations as SWG; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\Validator\Constraints; /** * @RouteResource("Timesheet") + * + * @Security("is_granted('ROLE_USER')") */ class TimesheetController extends BaseApiController { @@ -67,26 +70,29 @@ class TimesheetController extends BaseApiController } /** + * Returns a collection of timesheet records + * * @SWG\Response( * 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( * 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="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="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="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')") - * @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="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="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="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="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: ISO 8601)") + * @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. 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')") * @@ -141,6 +147,15 @@ class TimesheetController extends BaseApiController $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'))) { $exported = (int) $exported; if ($exported === 1) { @@ -161,35 +176,52 @@ class TimesheetController extends BaseApiController } /** + * Returns one timesheet record + * * @SWG\Response( * 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\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 * @return Response */ public function getAction($id) { - $data = $this->repository->find($id); - if (null === $data) { + $timesheet = $this->repository->find($id); + + if (null === $timesheet) { 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']); return $this->viewHandler->handle($view); } /** + * Creates a new timesheet record + * * @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( * response=200, - * description="Returns the new created timesheet entry", + * description="Returns the new created timesheet", * @SWG\Schema(ref="#/definitions/TimesheetEntity"), * ) * ) @@ -218,20 +250,16 @@ class TimesheetController extends BaseApiController 'csrf_protection' => false, 'include_rate' => $this->isGranted('edit_rate', $timesheet), 'include_exported' => $this->isGranted('edit_export', $timesheet), + 'date_format' => self::DATE_FORMAT, ]); $form->submit($request->request->all()); 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 (!$this->isGranted('start', $timesheet)) { + throw new AccessDeniedHttpException('You are not allowed to start this timesheet record'); + } $this->repository->stopActiveEntries( $timesheet->getUser(), $this->configuration->getActiveEntriesHardLimit() @@ -255,11 +283,13 @@ class TimesheetController extends BaseApiController } /** + * Update an existing timesheet record + * * @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( * response=200, - * description="Returns the updated timesheet entry", + * description="Returns the updated timesheet", * @SWG\Schema(ref="#/definitions/TimesheetEntity") * ) * ) @@ -269,23 +299,35 @@ class TimesheetController extends BaseApiController * required=true, * @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 string $id + * @param int $id the timesheet to update * @return Response */ - public function patchAction(Request $request, string $id) + public function patchAction(Request $request, int $id) { $timesheet = $this->repository->find($id); + if (null === $timesheet) { + throw new NotFoundException(); + } + 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, [ 'csrf_protection' => false, 'include_rate' => $this->isGranted('edit_rate', $timesheet), 'include_exported' => $this->isGranted('edit_export', $timesheet), + 'date_format' => self::DATE_FORMAT, ]); $form->setData($timesheet); diff --git a/src/API/UserController.php b/src/API/UserController.php index ff6c7e74..dd06169d 100644 --- a/src/API/UserController.php +++ b/src/API/UserController.php @@ -11,17 +11,22 @@ declare(strict_types=1); namespace App\API; -use App\Entity\User; +use App\Repository\Query\UserQuery; use App\Repository\UserRepository; +use FOS\RestBundle\Controller\Annotations as Rest; use FOS\RestBundle\Controller\Annotations\RouteResource; +use FOS\RestBundle\Request\ParamFetcherInterface; use FOS\RestBundle\View\View; use FOS\RestBundle\View\ViewHandlerInterface; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Swagger\Annotations as SWG; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** * @RouteResource("User") + * + * @Security("is_granted('ROLE_USER')") */ class UserController extends BaseApiController { @@ -46,22 +51,46 @@ class UserController extends BaseApiController } /** + * Returns the collection of all registered users + * * @SWG\Response( * response=200, - * description="Returns the collection of all registered users", + * description="Returns the collection of all registered users. Required permission: view_user", * @SWG\Schema( * 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')") * * @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->getContext()->setGroups(['Default', 'Collection', 'User']); @@ -69,24 +98,37 @@ class UserController extends BaseApiController } /** + * Return one user entity + * * @SWG\Response( * response=200, - * description="Return one user entity", + * description="Return one user entity. Required permission: view_user", * @SWG\Schema(ref="#/definitions/UserEntity"), * ) - * - * @Security("is_granted('view_user')") + * @SWG\Parameter( + * name="id", + * in="path", + * type="integer", + * description="User ID to fetch", + * required=true, + * ) * * @param int $id * @return Response */ public function getAction($id) { - $data = $this->repository->find($id); - if (null === $data) { + $user = $this->repository->find($id); + + if (null === $user) { 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']); return $this->viewHandler->handle($view); diff --git a/src/Controller/ActivityController.php b/src/Controller/ActivityController.php index 0cf19744..ba4c7d80 100644 --- a/src/Controller/ActivityController.php +++ b/src/Controller/ActivityController.php @@ -222,7 +222,9 @@ class ActivityController extends AbstractController return $this->createForm(ActivityEditForm::class, $activity, [ 'action' => $url, - 'method' => 'POST' + 'method' => 'POST', + 'create_more' => true, + 'customer' => true, ]); } } diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index 070ea5c8..b3e36450 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -214,14 +214,11 @@ class ProjectController extends AbstractController $currency = $project->getCustomer()->getCurrency(); } - return $this->createForm( - ProjectEditForm::class, - $project, - [ - 'action' => $url, - 'method' => 'POST', - 'currency' => $currency, - ] - ); + return $this->createForm(ProjectEditForm::class, $project, [ + 'action' => $url, + 'method' => 'POST', + 'currency' => $currency, + 'create_more' => true, + ]); } } diff --git a/src/Controller/TimesheetController.php b/src/Controller/TimesheetController.php index f3fabe56..da276d79 100644 --- a/src/Controller/TimesheetController.php +++ b/src/Controller/TimesheetController.php @@ -267,6 +267,7 @@ class TimesheetController extends AbstractController return $this->createForm(TimesheetEditForm::class, $entry, [ 'action' => $this->generateUrl('timesheet_create', ['origin' => $redirectRoute]), '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_exported' => $this->isGranted('edit_export', $entry), + 'customer' => true, ]); } diff --git a/src/Controller/TimesheetTeamController.php b/src/Controller/TimesheetTeamController.php index 7fe2ad8c..c5ffed79 100644 --- a/src/Controller/TimesheetTeamController.php +++ b/src/Controller/TimesheetTeamController.php @@ -189,6 +189,7 @@ class TimesheetTeamController extends AbstractController 'action' => $this->generateUrl('admin_timesheet_create'), 'include_rate' => $this->isGranted('edit_rate', $entry), 'include_user' => true, + 'customer' => true, ]); } @@ -208,6 +209,7 @@ class TimesheetTeamController extends AbstractController 'include_rate' => $this->isGranted('edit_rate', $entry), 'include_exported' => $this->isGranted('edit_export', $entry), 'include_user' => true, + 'customer' => true, ]); } diff --git a/src/DataFixtures/TimesheetFixtures.php b/src/DataFixtures/TimesheetFixtures.php index dfbdf08d..2b36b071 100644 --- a/src/DataFixtures/TimesheetFixtures.php +++ b/src/DataFixtures/TimesheetFixtures.php @@ -170,6 +170,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface $start = new \DateTime(); $start = $start->modify('- ' . (rand(1, self::TIMERANGE_DAYS)) . ' days'); $start = $start->modify('- ' . (rand(1, 86400)) . ' seconds'); + $start->setTimezone(new \DateTimeZone($user->getPreferenceValue(UserPreference::TIMEZONE, date_default_timezone_get()))); $entry = new Timesheet(); $entry diff --git a/src/DataFixtures/UserFixtures.php b/src/DataFixtures/UserFixtures.php index c429853c..de1cec14 100644 --- a/src/DataFixtures/UserFixtures.php +++ b/src/DataFixtures/UserFixtures.php @@ -87,7 +87,7 @@ class UserFixtures extends Fixture ->setEnabled($userData[6]) ->setPassword($passwordEncoder->encodePassword($user, self::DEFAULT_PASSWORD)) ->setApiToken($passwordEncoder->encodePassword($user, self::DEFAULT_API_TOKEN)) - ->setPreferences([$this->getUserPreference($user)]) + ->setPreferences($this->getUserPreferences($user, $userData[7])) ; $manager->persist($user); @@ -99,16 +99,28 @@ class UserFixtures extends Fixture /** * @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(); - $preference->setName(UserPreference::HOURLY_RATE); - $preference->setValue(rand(self::MIN_RATE, self::MAX_RATE)); - $preference->setUser($user); + $preferences = []; - 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) ->setEnabled(true) ->setPassword($passwordEncoder->encodePassword($user, self::DEFAULT_PASSWORD)) - ->setPreferences([$this->getUserPreference($user)]) + ->setPreferences($this->getUserPreferences($user)) ; if ($i % self::BATCH_SIZE == 0) { @@ -152,28 +164,68 @@ class UserFixtures extends Fixture */ 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 [ [ - 'John Doe', 'Developer', self::USERNAME_USER, 'john_user@example.com', User::ROLE_USER, - self::DEFAULT_AVATAR, true + 'John Doe', + 'Developer', + self::USERNAME_USER, + 'john_user@example.com', + User::ROLE_USER, + self::DEFAULT_AVATAR, + true, + 'America/Vancouver', ], // inactive user to test login [ - 'Chris Deactive', 'Developer (left company)', 'chris_user', 'chris_user@example.com', User::ROLE_USER, - self::DEFAULT_AVATAR, false + 'Chris Deactive', + '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, - 'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg', true + 'Tony Maier', + '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 [ - '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 [ - null, 'Super Administrator', self::USERNAME_SUPER_ADMIN, 'susan_super@example.com', User::ROLE_SUPER_ADMIN, - '/build/images/default_avatar.png', true + null, + 'Super Administrator', + self::USERNAME_SUPER_ADMIN, + 'susan_super@example.com', + User::ROLE_SUPER_ADMIN, + '/build/images/default_avatar.png', + true, + 'Europe/Berlin', ] ]; } diff --git a/src/Form/ActivityEditForm.php b/src/Form/ActivityEditForm.php index 23ef3557..5d882877 100644 --- a/src/Form/ActivityEditForm.php +++ b/src/Form/ActivityEditForm.php @@ -11,13 +11,14 @@ namespace App\Form; use App\Entity\Activity; use App\Form\Type\CustomerType; +use App\Form\Type\FixedRateType; +use App\Form\Type\HourlyRateType; use App\Form\Type\ProjectType; use App\Form\Type\YesNoType; use App\Repository\CustomerRepository; use App\Repository\ProjectRepository; use Symfony\Component\Form\AbstractType; 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\TextType; use Symfony\Component\Form\FormBuilderInterface; @@ -66,18 +67,23 @@ class ActivityEditForm extends AbstractType 'label' => 'label.comment', 'required' => false, ]) - ->add('customer', CustomerType::class, [ - 'label' => 'label.customer', - 'query_builder' => function (CustomerRepository $repo) use ($customer) { - return $repo->builderForEntityType($customer); - }, - 'data' => $customer ? $customer : null, - 'required' => false, - 'mapped' => false, - 'project_enabled' => true, - ]) + ; + + if ($options['customer']) { + $builder + ->add('customer', CustomerType::class, [ + 'query_builder' => function (CustomerRepository $repo) use ($customer) { + return $repo->builderForEntityType($customer); + }, + 'data' => $customer ? $customer : null, + 'required' => false, + 'mapped' => false, + 'project_enabled' => true, + ]); + } + + $builder ->add('project', ProjectType::class, [ - 'label' => 'label.project', 'required' => false, 'query_builder' => function (ProjectRepository $repo) use ($project, $customer) { return $repo->builderForEntityType($project, $customer); @@ -103,14 +109,10 @@ class ActivityEditForm extends AbstractType ); $builder - ->add('fixedRate', MoneyType::class, [ - 'label' => 'label.fixedRate', - 'required' => false, + ->add('fixedRate', FixedRateType::class, [ 'currency' => $currency, ]) - ->add('hourlyRate', MoneyType::class, [ - 'label' => 'label.hourlyRate', - 'required' => false, + ->add('hourlyRate', HourlyRateType::class, [ 'currency' => $currency, ]) // boolean @@ -119,7 +121,7 @@ class ActivityEditForm extends AbstractType ]) ; - if (null === $id) { + if (null === $id && $options['create_more']) { $builder->add('create_more', CheckboxType::class, [ 'label' => 'label.create_more', 'required' => false, @@ -138,6 +140,8 @@ class ActivityEditForm extends AbstractType 'csrf_protection' => true, 'csrf_field_name' => '_token', 'csrf_token_id' => 'admin_activity_edit', + 'create_more' => false, + 'customer' => false, ]); } } diff --git a/src/Form/CustomerEditForm.php b/src/Form/CustomerEditForm.php index 3ce613cd..6eb55739 100644 --- a/src/Form/CustomerEditForm.php +++ b/src/Form/CustomerEditForm.php @@ -10,12 +10,13 @@ namespace App\Form; use App\Entity\Customer; +use App\Form\Type\FixedRateType; +use App\Form\Type\HourlyRateType; use App\Form\Type\YesNoType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CountryType; use Symfony\Component\Form\Extension\Core\Type\CurrencyType; 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\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -34,8 +35,13 @@ class CustomerEditForm extends AbstractType */ public function buildForm(FormBuilderInterface $builder, array $options) { - /** @var Customer $customer */ - $customer = $options['data']; + $currency = false; + + if (isset($options['data'])) { + /** @var Customer $customer */ + $customer = $options['data']; + $currency = $customer->getCurrency(); + } $builder ->add('name', TextType::class, [ @@ -95,15 +101,11 @@ class CustomerEditForm extends AbstractType ->add('timezone', TimezoneType::class, [ 'label' => 'label.timezone', ]) - ->add('fixedRate', MoneyType::class, [ - 'label' => 'label.fixedRate', - 'required' => false, - 'currency' => $customer->getCurrency() ?? false, + ->add('fixedRate', FixedRateType::class, [ + 'currency' => $currency ?? false, ]) - ->add('hourlyRate', MoneyType::class, [ - 'label' => 'label.hourlyRate', - 'required' => false, - 'currency' => $customer->getCurrency() ?? false, + ->add('hourlyRate', HourlyRateType::class, [ + 'currency' => $currency ?? false, ]) ->add('visible', YesNoType::class, [ 'label' => 'label.visible', diff --git a/src/Form/ProjectEditForm.php b/src/Form/ProjectEditForm.php index 48a321b2..67c1082f 100644 --- a/src/Form/ProjectEditForm.php +++ b/src/Form/ProjectEditForm.php @@ -12,6 +12,8 @@ namespace App\Form; use App\Entity\Customer; use App\Entity\Project; use App\Form\Type\CustomerType; +use App\Form\Type\FixedRateType; +use App\Form\Type\HourlyRateType; use App\Form\Type\YesNoType; use App\Repository\CustomerRepository; use Symfony\Component\Form\AbstractType; @@ -32,15 +34,19 @@ class ProjectEditForm extends AbstractType */ public function buildForm(FormBuilderInterface $builder, array $options) { - /** @var Project $entry */ - $entry = $options['data']; - $customer = null; $currency = false; + $id = null; - if ($entry->getId() !== null) { - $customer = $entry->getCustomer(); - $currency = $customer->getCurrency(); + if (isset($options['data'])) { + /** @var Project $entry */ + $entry = $options['data']; + $id = $entry->getId(); + + if ($id !== null) { + $customer = $entry->getCustomer(); + $currency = $customer->getCurrency(); + } } $builder @@ -59,19 +65,14 @@ class ProjectEditForm extends AbstractType 'required' => false, ]) ->add('customer', CustomerType::class, [ - 'label' => 'label.customer', 'query_builder' => function (CustomerRepository $repo) use ($customer) { return $repo->builderForEntityType($customer); }, ]) - ->add('fixedRate', MoneyType::class, [ - 'label' => 'label.fixedRate', - 'required' => false, + ->add('fixedRate', FixedRateType::class, [ 'currency' => $currency, ]) - ->add('hourlyRate', MoneyType::class, [ - 'label' => 'label.hourlyRate', - 'required' => false, + ->add('hourlyRate', HourlyRateType::class, [ 'currency' => $currency, ]) ->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, [ 'label' => 'label.create_more', 'required' => false, @@ -104,6 +105,7 @@ class ProjectEditForm extends AbstractType 'csrf_field_name' => '_token', 'csrf_token_id' => 'admin_project_edit', 'currency' => Customer::DEFAULT_CURRENCY, + 'create_more' => false, ]); } } diff --git a/src/Form/TimesheetEditForm.php b/src/Form/TimesheetEditForm.php index 039bfe46..188f122f 100644 --- a/src/Form/TimesheetEditForm.php +++ b/src/Form/TimesheetEditForm.php @@ -15,6 +15,8 @@ use App\Form\Type\ActivityType; use App\Form\Type\CustomerType; use App\Form\Type\DateTimePickerType; use App\Form\Type\DurationType; +use App\Form\Type\FixedRateType; +use App\Form\Type\HourlyRateType; use App\Form\Type\ProjectType; use App\Form\Type\UserType; use App\Form\Type\YesNoType; @@ -23,7 +25,6 @@ use App\Repository\CustomerRepository; use App\Repository\ProjectRepository; use App\Timesheet\UserDateTimeFactory; 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\FormBuilderInterface; use Symfony\Component\Form\FormEvent; @@ -77,6 +78,7 @@ class TimesheetEditForm extends AbstractType $currency = false; $end = null; $begin = null; + $customerCount = $this->customers->countCustomer(true); if (isset($options['data'])) { /** @var Timesheet $entry */ @@ -104,12 +106,20 @@ class TimesheetEditForm extends AbstractType $timezone = $begin->getTimezone()->getName(); } + $dateTimeOptions = [ + 'model_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, [ - 'label' => 'label.begin', - 'model_timezone' => $timezone, - 'view_timezone' => $timezone, - ]); + $builder->add('begin', DateTimePickerType::class, array_merge($dateTimeOptions, [ + 'label' => 'label.begin' + ])); } if ($this->configuration->isDurationOnly()) { @@ -126,7 +136,7 @@ class TimesheetEditForm extends AbstractType function (FormEvent $event) { /** @var Timesheet $data */ $data = $event->getData(); - if (null === $data->getEnd()) { + if (null === $data || null === $data->getEnd()) { $event->getForm()->get('duration')->setData(null); } } @@ -148,24 +158,19 @@ class TimesheetEditForm extends AbstractType } ); } else { - $builder->add('end', DateTimePickerType::class, [ + $builder->add('end', DateTimePickerType::class, array_merge($dateTimeOptions, [ 'label' => 'label.end', - 'model_timezone' => $timezone, - 'view_timezone' => $timezone, 'required' => false, - ]); + ])); } $projectOptions = []; - if ($this->customers->countCustomer(true) > 1) { + if ($customerCount < 2) { + $projectOptions['group_by'] = null; + } elseif ($options['customer']) { $builder ->add('customer', CustomerType::class, [ - // documentation is for NelmioApiDocBundle - 'documentation' => [ - 'type' => 'integer', - 'description' => 'Customer ID', - ], 'query_builder' => function (CustomerRepository $repo) use ($customer) { return $repo->builderForEntityType($customer); }, @@ -175,8 +180,6 @@ class TimesheetEditForm extends AbstractType 'mapped' => false, 'project_enabled' => true, ]); - } else { - $projectOptions['group_by'] = null; } if ($this->projects->countProject(true) <= 1) { @@ -188,16 +191,11 @@ class TimesheetEditForm extends AbstractType 'project', ProjectType::class, array_merge($projectOptions, [ - 'placeholder' => '', - 'activity_enabled' => true, - // documentation is for NelmioApiDocBundle - 'documentation' => [ - 'type' => 'integer', - 'description' => 'Project ID', - ], - 'query_builder' => function (ProjectRepository $repo) use ($project, $customer) { - return $repo->builderForEntityType($project, $customer); - }, + 'placeholder' => '', + 'activity_enabled' => true, + 'query_builder' => function (ProjectRepository $repo) use ($project, $customer) { + return $repo->builderForEntityType($project, $customer); + }, ]) ); @@ -223,12 +221,7 @@ class TimesheetEditForm extends AbstractType $builder ->add('activity', ActivityType::class, [ - // documentation is for NelmioApiDocBundle 'placeholder' => '', - 'documentation' => [ - 'type' => 'integer', - 'description' => 'Activity ID', - ], 'query_builder' => function (ActivityRepository $repo) use ($activity, $project) { return $repo->builderForEntityType($activity, $project); }, @@ -262,20 +255,10 @@ class TimesheetEditForm extends AbstractType if ($options['include_rate']) { $builder - ->add('fixedRate', MoneyType::class, [ - 'documentation' => [ - 'type' => 'float' - ], - 'label' => 'label.fixedRate', - 'required' => false, + ->add('fixedRate', FixedRateType::class, [ 'currency' => $currency, ]) - ->add('hourlyRate', MoneyType::class, [ - 'documentation' => [ - 'type' => 'float' - ], - 'label' => 'label.hourlyRate', - 'required' => false, + ->add('hourlyRate', HourlyRateType::class, [ 'currency' => $currency, ]); } @@ -306,6 +289,8 @@ class TimesheetEditForm extends AbstractType 'include_rate' => true, 'docu_chapter' => 'timesheet.html', 'method' => 'POST', + 'date_format' => null, + 'customer' => false, ]); } } diff --git a/src/Form/Type/ActivityType.php b/src/Form/Type/ActivityType.php index ea0fe395..759f1375 100644 --- a/src/Form/Type/ActivityType.php +++ b/src/Form/Type/ActivityType.php @@ -67,6 +67,11 @@ class ActivityType extends AbstractType public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ + // documentation is for NelmioApiDocBundle + 'documentation' => [ + 'type' => 'integer', + 'description' => 'Activity ID', + ], 'label' => 'label.activity', 'class' => Activity::class, 'choice_label' => [$this, 'choiceLabel'], diff --git a/src/Form/Type/CustomerType.php b/src/Form/Type/CustomerType.php index e38fa087..574b5330 100644 --- a/src/Form/Type/CustomerType.php +++ b/src/Form/Type/CustomerType.php @@ -28,6 +28,11 @@ class CustomerType extends AbstractType public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ + // documentation is for NelmioApiDocBundle + 'documentation' => [ + 'type' => 'integer', + 'description' => 'Customer ID', + ], 'label' => 'label.customer', 'class' => Customer::class, 'choice_label' => 'name', diff --git a/src/Form/Type/DateTimePickerType.php b/src/Form/Type/DateTimePickerType.php index c7f7de89..72abd291 100644 --- a/src/Form/Type/DateTimePickerType.php +++ b/src/Form/Type/DateTimePickerType.php @@ -9,6 +9,7 @@ namespace App\Form\Type; +use App\API\BaseApiController; use App\Timesheet\UserDateTimeFactory; use App\Utils\LocaleSettings; use Symfony\Component\Form\AbstractType; @@ -50,6 +51,11 @@ class DateTimePickerType extends AbstractType $timezone = $this->dateTime->getTimezone()->getName(); $resolver->setDefaults([ + 'documentation' => [ + 'type' => 'string', + 'format' => 'date-time', + 'example' => (new \DateTime())->format(BaseApiController::DATE_FORMAT_PHP), + ], 'label' => 'label.begin', 'widget' => 'single_text', 'html5' => false, diff --git a/src/Form/Type/FixedRateType.php b/src/Form/Type/FixedRateType.php new file mode 100644 index 00000000..432ec6c4 --- /dev/null +++ b/src/Form/Type/FixedRateType.php @@ -0,0 +1,44 @@ +setDefaults([ + // documentation is for NelmioApiDocBundle + 'documentation' => [ + 'type' => 'number', + 'description' => 'Fixed rate', + ], + 'required' => false, + 'label' => 'label.fixedRate', + ]); + } + + /** + * {@inheritdoc} + */ + public function getParent() + { + return MoneyType::class; + } +} diff --git a/src/Form/Type/HourlyRateType.php b/src/Form/Type/HourlyRateType.php new file mode 100644 index 00000000..56b60f86 --- /dev/null +++ b/src/Form/Type/HourlyRateType.php @@ -0,0 +1,44 @@ +setDefaults([ + // documentation is for NelmioApiDocBundle + 'documentation' => [ + 'type' => 'number', + 'description' => 'Hourly rate', + ], + 'required' => false, + 'label' => 'label.hourlyRate', + ]); + } + + /** + * {@inheritdoc} + */ + public function getParent() + { + return MoneyType::class; + } +} diff --git a/src/Form/Type/ProjectType.php b/src/Form/Type/ProjectType.php index 21a6c726..ccabe645 100644 --- a/src/Form/Type/ProjectType.php +++ b/src/Form/Type/ProjectType.php @@ -49,6 +49,11 @@ class ProjectType extends AbstractType public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ + // documentation is for NelmioApiDocBundle + 'documentation' => [ + 'type' => 'integer', + 'description' => 'Project ID', + ], 'label' => 'label.project', 'class' => Project::class, 'choice_label' => 'name', diff --git a/src/Form/UserEditType.php b/src/Form/UserEditType.php index 1ef8cdbb..743ab842 100644 --- a/src/Form/UserEditType.php +++ b/src/Form/UserEditType.php @@ -16,14 +16,12 @@ use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; -use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; /** * Defines the form used to edit the profile of a User. */ class UserEditType extends AbstractType { - /** * {@inheritdoc} */ diff --git a/src/Repository/Query/BaseQuery.php b/src/Repository/Query/BaseQuery.php index 329dc9f3..6d028a6d 100644 --- a/src/Repository/Query/BaseQuery.php +++ b/src/Repository/Query/BaseQuery.php @@ -43,7 +43,7 @@ class BaseQuery /** * @var string */ - protected $order = 'ASC'; + protected $order = self::ORDER_ASC; /** * @var string */ diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 956c334e..87de80af 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -50,7 +50,7 @@ class UserRepository extends AbstractRepository implements UserLoaderInterface /** * @param UserQuery $query - * @return \Pagerfanta\Pagerfanta + * @return array|\Doctrine\ORM\QueryBuilder|\Pagerfanta\Pagerfanta */ public function findByQuery(UserQuery $query) { @@ -77,7 +77,7 @@ class UserRepository extends AbstractRepository implements UserLoaderInterface $qb->andWhere($rolesWhere); } - return $this->getPager($qb->getQuery(), $query->getPage(), $query->getPageSize()); + return $this->getBaseQueryResult($qb, $query); } /** diff --git a/src/Voter/TimesheetVoter.php b/src/Voter/TimesheetVoter.php index 8e61ca97..95ce5ce9 100644 --- a/src/Voter/TimesheetVoter.php +++ b/src/Voter/TimesheetVoter.php @@ -18,6 +18,7 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; */ class TimesheetVoter extends AbstractVoter { + public const VIEW = 'view'; public const START = 'start'; public const STOP = 'stop'; public const EDIT = 'edit'; @@ -31,6 +32,7 @@ class TimesheetVoter extends AbstractVoter * support rules based on the given $subject (here: Timesheet) */ public const ALLOWED_ATTRIBUTES = [ + self::VIEW, self::START, self::STOP, self::EDIT, @@ -87,6 +89,7 @@ class TimesheetVoter extends AbstractVoter case self::EDIT_RATE: case self::STOP: case self::EDIT: + case self::VIEW: case self::DELETE: case self::EXPORT: case self::EDIT_EXPORT: diff --git a/symfony.lock b/symfony.lock index 46d064b6..d9ef9b05 100644 --- a/symfony.lock +++ b/symfony.lock @@ -143,6 +143,45 @@ "gedmo/doctrine-extensions": { "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": { "version": "v1.2.17" }, diff --git a/templates/about/system.html.twig b/templates/about/system.html.twig index 62a0e013..1855a244 100644 --- a/templates/about/system.html.twig +++ b/templates/about/system.html.twig @@ -15,6 +15,7 @@
@@ -123,6 +124,24 @@ wikipedia.org

+
+

Special thanks to the authors of the following libraries, Kimai wouldn't be possible without them:

+ +
diff --git a/templates/bundles/NelmioApiDocBundle/SwaggerUi/index.html.twig b/templates/bundles/NelmioApiDocBundle/SwaggerUi/index.html.twig index 37aeb53e..6b3f03bf 100644 --- a/templates/bundles/NelmioApiDocBundle/SwaggerUi/index.html.twig +++ b/templates/bundles/NelmioApiDocBundle/SwaggerUi/index.html.twig @@ -1,18 +1,9 @@ - - - - - {{ swagger_data.spec.info.title }} - - - - {# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #} - - - -
- - - - - +{% extends '@!NelmioApiDoc/SwaggerUi/index.html.twig' %} + +{% block stylesheets %} + {{ parent() }} + +{% endblock %} \ No newline at end of file diff --git a/tests/API/APIControllerBaseTest.php b/tests/API/APIControllerBaseTest.php index 58eee53e..4031a196 100644 --- a/tests/API/APIControllerBaseTest.php +++ b/tests/API/APIControllerBaseTest.php @@ -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 string[] $failedFields diff --git a/tests/API/ActivityControllerTest.php b/tests/API/ActivityControllerTest.php index 272f0971..94025e86 100644 --- a/tests/API/ActivityControllerTest.php +++ b/tests/API/ActivityControllerTest.php @@ -13,7 +13,6 @@ use App\Entity\Activity; use App\Entity\Customer; use App\Entity\Project; use App\Entity\User; -use App\Repository\Query\VisibilityQuery; use Symfony\Bundle\FrameworkBundle\Client; use Symfony\Component\HttpFoundation\Response; @@ -77,7 +76,7 @@ class ActivityControllerTest extends APIControllerBaseTest for ($i = 0; $i < count($result); $i++) { $activity = $result[$i]; $hasProject = $expected[$i][0]; - $this->assertStructure($activity, $hasProject); + $this->assertStructure($activity, false); if ($hasProject) { $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', ['globals' => 'true'], [[false], [false]]]; - yield ['/api/activities', ['globals' => 'true', 'visible' => VisibilityQuery::SHOW_BOTH], [[false], [false], [false]]]; - yield ['/api/activities', ['globals' => 'true', 'visible' => VisibilityQuery::SHOW_HIDDEN], [[false]]]; - yield ['/api/activities', ['globals' => 'true', 'visible' => VisibilityQuery::SHOW_VISIBLE], [[false], [false]]]; + yield ['/api/activities', ['globals' => 'true', 'visible' => 3], [[false], [false], [false]]]; + yield ['/api/activities', ['globals' => 'true', 'visible' => '2'], [[false]]]; + yield ['/api/activities', ['globals' => 'true', 'visible' => 1], [[false], [false]]]; 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' => VisibilityQuery::SHOW_BOTH], [[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' => 1], [[false], [false], [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' => 2], [[false], [true, 2]]]; } public function testGetCollectionWithQuery() @@ -123,10 +122,12 @@ class ActivityControllerTest extends APIControllerBaseTest $result = json_decode($client->getResponse()->getContent(), true); $this->assertIsArray($result); + $this->assertStructure($result, true); + } - $expectedKeys = ['id', 'name', 'comment', 'visible']; - $actual = array_keys($result); - $this->assertEquals($expectedKeys, $actual); + public function testNotFound() + { + $this->assertEntityNotFound(User::ROLE_USER, '/api/activities/2'); } public function testPostAction() @@ -134,7 +135,6 @@ class ActivityControllerTest extends APIControllerBaseTest $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $data = [ 'name' => 'foo', - 'customer' => 1, 'project' => 1, 'visible' => true ]; @@ -152,7 +152,6 @@ class ActivityControllerTest extends APIControllerBaseTest $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $data = [ 'name' => 'foo', - 'customer' => 1, 'project' => 1, 'visible' => true ]; @@ -164,18 +163,12 @@ class ActivityControllerTest extends APIControllerBaseTest $this->assertEquals('User cannot create activities', $json['message']); } - public function testNotFound() - { - $this->assertEntityNotFound(User::ROLE_USER, '/api/activities/2'); - } - public function testPatchAction() { $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $data = [ 'name' => 'foo', 'comment' => '', - 'customer' => 1, 'project' => 1, 'visible' => true ]; @@ -195,11 +188,10 @@ class ActivityControllerTest extends APIControllerBaseTest $data = [ 'name' => 'foo', 'comment' => '', - 'customer' => 1, 'project' => 1, '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(); $this->assertFalse($response->isSuccessful()); $this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode()); @@ -207,13 +199,17 @@ class ActivityControllerTest extends APIControllerBaseTest $this->assertEquals('User cannot update activity', $json['message']); } + public function testPatchActionWithUnknownActivity() + { + $this->assertEntityNotFoundForPatch(User::ROLE_USER, '/api/activities/255', []); + } + public function testInvalidPatchAction() { $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $data = [ 'name' => 'foo', - 'customer' => 255, - 'project' => 1, + 'project' => 255, 'visible' => true ]; $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) { - $expectedKeys = ['id', 'name', 'visible']; + $expectedKeys = ['id', 'name', 'visible', 'project', 'hourlyRate', 'fixedRate']; if ($full) { - $expectedKeys = ['id', 'name', 'visible', 'project']; + $expectedKeys = array_merge($expectedKeys, ['comment']); } $actual = array_keys($result); diff --git a/tests/API/ConfigurationControllerTest.php b/tests/API/ConfigurationControllerTest.php index 3741c976..6ae90edd 100644 --- a/tests/API/ConfigurationControllerTest.php +++ b/tests/API/ConfigurationControllerTest.php @@ -36,7 +36,7 @@ class ConfigurationControllerTest extends APIControllerBaseTest 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); sort($actual); sort($expectedKeys); diff --git a/tests/API/CustomerControllerTest.php b/tests/API/CustomerControllerTest.php index 5bce331b..9d16e212 100644 --- a/tests/API/CustomerControllerTest.php +++ b/tests/API/CustomerControllerTest.php @@ -10,6 +10,7 @@ namespace App\Tests\API; use App\Entity\User; +use Symfony\Component\HttpFoundation\Response; /** * @coversDefaultClass \App\API\CustomerController @@ -62,15 +63,113 @@ class CustomerControllerTest extends APIControllerBaseTest $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) { - $expectedKeys = ['id', 'name', 'visible']; + $expectedKeys = ['id', 'name', 'visible', 'hourlyRate', 'fixedRate']; if ($full) { - $expectedKeys = [ - 'id', 'name', 'number', 'comment', 'visible', 'company', 'contact', 'address', 'country', 'currency', - 'phone', 'fax', 'mobile', 'email', 'timezone' - ]; + $expectedKeys = array_merge($expectedKeys, [ + 'homepage', 'number', 'comment', 'company', 'contact', 'address', 'country', 'currency', 'phone', 'fax', 'mobile', 'email', 'timezone' + ]); } $actual = array_keys($result); diff --git a/tests/API/ProjectControllerTest.php b/tests/API/ProjectControllerTest.php index 39654f7a..9fff37fc 100644 --- a/tests/API/ProjectControllerTest.php +++ b/tests/API/ProjectControllerTest.php @@ -14,6 +14,7 @@ use App\Entity\Project; use App\Entity\User; use App\Repository\Query\VisibilityQuery; use Symfony\Bundle\FrameworkBundle\Client; +use Symfony\Component\HttpFoundation\Response; /** * @coversDefaultClass \App\API\ProjectController @@ -120,16 +121,107 @@ class ProjectControllerTest extends APIControllerBaseTest $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) { $expectedKeys = [ - 'id', 'name', 'comment', 'visible', 'budget', 'order_number', 'customer' + 'id', 'name', 'visible', 'customer', 'hourlyRate', 'fixedRate' ]; - if (!$full) { - $expectedKeys = [ - 'id', 'name', 'visible', 'customer' - ]; + if ($full) { + $expectedKeys = array_merge( + $expectedKeys, + ['comment', 'budget', 'orderNumber'] + ); } $actual = array_keys($result); diff --git a/tests/API/HealthcheckControllerTest.php b/tests/API/StatusControllerTest.php similarity index 94% rename from tests/API/HealthcheckControllerTest.php rename to tests/API/StatusControllerTest.php index bd0910b7..1e2892be 100644 --- a/tests/API/HealthcheckControllerTest.php +++ b/tests/API/StatusControllerTest.php @@ -13,10 +13,10 @@ use App\Constants; use App\Entity\User; /** - * @coversDefaultClass \App\API\HealthcheckController + * @coversDefaultClass \App\API\StatusController * @group integration */ -class HealthcheckControllerTest extends APIControllerBaseTest +class StatusControllerTest extends APIControllerBaseTest { public function testIsSecure() { diff --git a/tests/API/TimesheetControllerTest.php b/tests/API/TimesheetControllerTest.php index c7df2101..01479eb8 100644 --- a/tests/API/TimesheetControllerTest.php +++ b/tests/API/TimesheetControllerTest.php @@ -24,7 +24,12 @@ class TimesheetControllerTest extends APIControllerBaseTest { 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'); $fixture = new TimesheetFixtures(); @@ -32,8 +37,8 @@ class TimesheetControllerTest extends APIControllerBaseTest ->setFixedRate(true) ->setHourlyRate(true) ->setAmount(10) - ->setUser($this->getUserByRole($em, User::ROLE_USER)) - ->setStartDate(new \DateTime('-10 days')) + ->setUser($this->getUserByRole($em, $role)) + ->setStartDate((new \DateTime('-10 days'))->setTime(0, 0, 1)) ->setAllowEmptyDescriptions(false) ; $this->importFixture($em, $fixture); @@ -132,6 +137,7 @@ class TimesheetControllerTest extends APIControllerBaseTest 'size' => 5, 'order' => 'DESC', 'orderBy' => 'rate', + 'active' => 0, 'begin' => $begin->format('Y-m-d H:i:s'), 'end' => $end->format('Y-m-d H:i:s'), 'exported' => 0, @@ -224,6 +230,29 @@ class TimesheetControllerTest extends APIControllerBaseTest $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() { $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); @@ -265,8 +294,8 @@ class TimesheetControllerTest extends APIControllerBaseTest $data = [ 'activity' => $activity->getId(), 'project' => $project->getId(), - 'begin' => (new \DateTime('- 8 hours'))->format('Y-m-d H:m'), - 'end' => (new \DateTime())->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:s'), 'description' => 'foo', 'fixedRate' => 2016, 'hourlyRate' => 127 @@ -303,19 +332,14 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertApiCallValidationError($client->getResponse(), ['activity']); } - public function testNotFound() - { - $this->assertEntityNotFound(User::ROLE_USER, '/api/timesheets/20'); - } - public function testPatchAction() { $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $data = [ 'activity' => 1, 'project' => 1, - 'begin' => (new \DateTime('- 7 hours'))->format('Y-m-d H:m'), - 'end' => (new \DateTime())->format('Y-m-d H:m'), + 'begin' => (new \DateTime('- 7 hours'))->format('Y-m-d\TH:m'), + 'end' => (new \DateTime())->format('Y-m-d\TH:m'), 'description' => 'foo', 'exported' => true, ]; @@ -349,8 +373,8 @@ class TimesheetControllerTest extends APIControllerBaseTest $data = [ 'activity' => 1, 'project' => 1, - 'begin' => (new \DateTime('- 7 hours'))->format('Y-m-d H:m'), - 'end' => (new \DateTime())->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\TH:m:s'), 'description' => 'foo', 'exported' => true, ]; @@ -359,7 +383,12 @@ class TimesheetControllerTest extends APIControllerBaseTest $this->assertFalse($response->isSuccessful()); $this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode()); $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() @@ -387,7 +416,7 @@ class TimesheetControllerTest extends APIControllerBaseTest if ($full) { $expectedKeys = array_merge($expectedKeys, [ - 'exported', 'fixed_rate', 'hourly_rate' + 'exported', 'fixedRate', 'hourlyRate' ]); } diff --git a/tests/API/UserControllerTest.php b/tests/API/UserControllerTest.php index ee5a929c..97d7d9d1 100644 --- a/tests/API/UserControllerTest.php +++ b/tests/API/UserControllerTest.php @@ -29,10 +29,40 @@ class UserControllerTest extends APIControllerBaseTest $this->assertAccessIsGranted($client, '/api/users'); $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->assertNotEmpty($result); $this->assertEquals(6, count($result)); - $this->assertStructure($result[0], false); + foreach ($result as $user) { + $this->assertStructure($user, false); + } } public function testGetEntity() @@ -50,12 +80,31 @@ class UserControllerTest extends APIControllerBaseTest $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) { $expectedKeys = ['id', 'username', 'enabled', 'alias']; if ($full) { - $expectedKeys = ['id', 'username', 'enabled', 'roles', 'alias', 'title', 'avatar']; + $expectedKeys = array_merge( + $expectedKeys, + ['title', 'avatar', 'roles', 'language', 'timezone'] + ); } $actual = array_keys($result); diff --git a/tests/Controller/AboutControllerTest.php b/tests/Controller/AboutControllerTest.php index db17a6c4..3d7a9831 100644 --- a/tests/Controller/AboutControllerTest.php +++ b/tests/Controller/AboutControllerTest.php @@ -29,9 +29,9 @@ class AboutControllerTest extends ControllerBaseTest $this->assertAccessIsGranted($client, '/admin/about'); $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'); - $this->assertEquals(2, count($result)); + $this->assertEquals(3, count($result)); } } diff --git a/tests/Controller/ActivityControllerTest.php b/tests/Controller/ActivityControllerTest.php index bff5bdc8..bd663363 100644 --- a/tests/Controller/ActivityControllerTest.php +++ b/tests/Controller/ActivityControllerTest.php @@ -93,6 +93,26 @@ class ActivityControllerTest extends ControllerBaseTest } 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); $this->assertAccessIsGranted($client, '/admin/activity/1/edit'); diff --git a/tests/DataFixtures/TimesheetFixtures.php b/tests/DataFixtures/TimesheetFixtures.php index 77c0d559..1f9b25b2 100644 --- a/tests/DataFixtures/TimesheetFixtures.php +++ b/tests/DataFixtures/TimesheetFixtures.php @@ -284,7 +284,7 @@ class TimesheetFixtures extends Fixture private function createTimesheetEntry(User $user, Activity $activity, Project $project, $description, \DateTime $start, $setEndDate = true) { $end = clone $start; - $end = $end->modify('+ ' . (rand(1, 172800)) . ' seconds'); + $end = $end->modify('+ ' . (rand(1, 86400)) . ' seconds'); $duration = $end->getTimestamp() - $start->getTimestamp(); $hourlyRate = (float) $user->getPreferenceValue(UserPreference::HOURLY_RATE); diff --git a/translations/about.de.xliff b/translations/about.de.xliff index 31e556d4..b468b615 100644 --- a/translations/about.de.xliff +++ b/translations/about.de.xliff @@ -18,6 +18,10 @@ tab.license Kimai Lizenz + + tab.thanks + Danke! + diff --git a/translations/about.en.xliff b/translations/about.en.xliff index ec625d7e..d04e5db4 100644 --- a/translations/about.en.xliff +++ b/translations/about.en.xliff @@ -18,6 +18,10 @@ tab.license Kimai License + + tab.thanks + Special thanks +