Compare commits

..

3 Commits

Author SHA1 Message Date
Kevin Papst
8a42c9640f Release 2.43 (#5694) 2025-11-14 17:41:33 +01:00
Kevin Papst
8e6764b67a Release 2.42 (#5686) 2025-11-12 16:15:04 +01:00
Weblate (bot)
ea29799b89 Translated using Weblate (#5690)
Co-authored-by: Lasse Edsvik <lasse@lasseedsvik.se>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Santiago Atienza Ferro <satienza@gmail.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
2025-11-12 15:40:55 +01:00
64 changed files with 706 additions and 528 deletions

View File

@@ -40,7 +40,7 @@ APP_SECRET=change_this_to_something_unique
#================================================================================
# Running behind reverse proxies? Try these:
# TRUSTED_PROXIES=127.0.0.1,127.0.0.2
# TRUSTED_HOSTS=localhost,example.com
# TRUSTED_HOSTS=localhost|example.com
#================================================================================
# unlikely, that you need to change this one

View File

@@ -6,7 +6,7 @@
*/
/*!
* [KIMAI] KimaiRecentActivities: responsible to reload the users recent activities
* [KIMAI] KimaiRemoteModal: load remote content (without forms) into a modal
*/
import KimaiPlugin from '../KimaiPlugin';

110
composer.lock generated
View File

@@ -1412,16 +1412,16 @@
},
{
"name": "doctrine/orm",
"version": "2.20.7",
"version": "2.20.8",
"source": {
"type": "git",
"url": "https://github.com/doctrine/orm.git",
"reference": "59938cae57c88b386cb79c81685426c83d27a120"
"reference": "5bff0919a78c86238536a9b5396024fe3603b5d1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/orm/zipball/59938cae57c88b386cb79c81685426c83d27a120",
"reference": "59938cae57c88b386cb79c81685426c83d27a120",
"url": "https://api.github.com/repos/doctrine/orm/zipball/5bff0919a78c86238536a9b5396024fe3603b5d1",
"reference": "5bff0919a78c86238536a9b5396024fe3603b5d1",
"shasum": ""
},
"require": {
@@ -1451,7 +1451,7 @@
"doctrine/coding-standard": "^9.0.2 || ^14.0",
"phpbench/phpbench": "^0.16.10 || ^1.0",
"phpstan/extension-installer": "~1.1.0 || ^1.4",
"phpstan/phpstan": "~1.4.10 || 2.1.22",
"phpstan/phpstan": "~1.4.10 || 2.1.23",
"phpstan/phpstan-deprecation-rules": "^1 || ^2",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6",
"psr/log": "^1 || ^2 || ^3",
@@ -1507,9 +1507,9 @@
],
"support": {
"issues": "https://github.com/doctrine/orm/issues",
"source": "https://github.com/doctrine/orm/tree/2.20.7"
"source": "https://github.com/doctrine/orm/tree/2.20.8"
},
"time": "2025-10-27T21:19:59+00:00"
"time": "2025-11-10T13:35:45+00:00"
},
{
"name": "doctrine/persistence",
@@ -3463,16 +3463,16 @@
},
{
"name": "nelmio/api-doc-bundle",
"version": "v5.6.5",
"version": "v5.7.1",
"source": {
"type": "git",
"url": "https://github.com/nelmio/NelmioApiDocBundle.git",
"reference": "eb0453607560e63bbbc6a746978933f77a787575"
"reference": "772d26a9d940c9a8d173f75f0fdbaab47c1c87f2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/eb0453607560e63bbbc6a746978933f77a787575",
"reference": "eb0453607560e63bbbc6a746978933f77a787575",
"url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/772d26a9d940c9a8d173f75f0fdbaab47c1c87f2",
"reference": "772d26a9d940c9a8d173f75f0fdbaab47c1c87f2",
"shasum": ""
},
"require": {
@@ -3574,7 +3574,7 @@
],
"support": {
"issues": "https://github.com/nelmio/NelmioApiDocBundle/issues",
"source": "https://github.com/nelmio/NelmioApiDocBundle/tree/v5.6.5"
"source": "https://github.com/nelmio/NelmioApiDocBundle/tree/v5.7.1"
},
"funding": [
{
@@ -3582,7 +3582,7 @@
"type": "github"
}
],
"time": "2025-10-20T08:34:48+00:00"
"time": "2025-11-13T10:32:15+00:00"
},
{
"name": "nelmio/cors-bundle",
@@ -7357,16 +7357,16 @@
},
{
"name": "symfony/http-foundation",
"version": "v6.4.28",
"version": "v6.4.29",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
"reference": "1ba1d5fe6465b0fa39c8627ba552b2c29301aa81"
"reference": "b03d11e015552a315714c127d8d1e0f9e970ec88"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/1ba1d5fe6465b0fa39c8627ba552b2c29301aa81",
"reference": "1ba1d5fe6465b0fa39c8627ba552b2c29301aa81",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/b03d11e015552a315714c127d8d1e0f9e970ec88",
"reference": "b03d11e015552a315714c127d8d1e0f9e970ec88",
"shasum": ""
},
"require": {
@@ -7414,7 +7414,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-foundation/tree/v6.4.28"
"source": "https://github.com/symfony/http-foundation/tree/v6.4.29"
},
"funding": [
{
@@ -7434,20 +7434,20 @@
"type": "tidelift"
}
],
"time": "2025-11-06T10:41:18+00:00"
"time": "2025-11-08T16:40:12+00:00"
},
{
"name": "symfony/http-kernel",
"version": "v6.4.28",
"version": "v6.4.29",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
"reference": "bbccadab358e236fcf13cc02a01fc0115d09d787"
"reference": "18818b48f54c1d2bd92b41d82d8345af50b15658"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/bbccadab358e236fcf13cc02a01fc0115d09d787",
"reference": "bbccadab358e236fcf13cc02a01fc0115d09d787",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/18818b48f54c1d2bd92b41d82d8345af50b15658",
"reference": "18818b48f54c1d2bd92b41d82d8345af50b15658",
"shasum": ""
},
"require": {
@@ -7532,7 +7532,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-kernel/tree/v6.4.28"
"source": "https://github.com/symfony/http-kernel/tree/v6.4.29"
},
"funding": [
{
@@ -7552,7 +7552,7 @@
"type": "tidelift"
}
],
"time": "2025-11-06T20:52:25+00:00"
"time": "2025-11-12T11:22:59+00:00"
},
{
"name": "symfony/intl",
@@ -9864,16 +9864,16 @@
},
{
"name": "symfony/validator",
"version": "v6.4.27",
"version": "v6.4.29",
"source": {
"type": "git",
"url": "https://github.com/symfony/validator.git",
"reference": "60dd71e219cd3d76fde906eb6b6c1271db628f5b"
"reference": "99df8a769e64e399f510166141ea74f450e8dd1d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/validator/zipball/60dd71e219cd3d76fde906eb6b6c1271db628f5b",
"reference": "60dd71e219cd3d76fde906eb6b6c1271db628f5b",
"url": "https://api.github.com/repos/symfony/validator/zipball/99df8a769e64e399f510166141ea74f450e8dd1d",
"reference": "99df8a769e64e399f510166141ea74f450e8dd1d",
"shasum": ""
},
"require": {
@@ -9941,7 +9941,7 @@
"description": "Provides tools to validate values",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/validator/tree/v6.4.27"
"source": "https://github.com/symfony/validator/tree/v6.4.29"
},
"funding": [
{
@@ -9961,7 +9961,7 @@
"type": "tidelift"
}
],
"time": "2025-10-23T19:49:35+00:00"
"time": "2025-11-06T20:26:06+00:00"
},
{
"name": "symfony/var-dumper",
@@ -10921,16 +10921,16 @@
},
{
"name": "zircote/swagger-php",
"version": "5.5.2",
"version": "5.7.0",
"source": {
"type": "git",
"url": "https://github.com/zircote/swagger-php.git",
"reference": "0ca908380414596f5ed3a7ad33a04abb4cffe613"
"reference": "39fcd46e79c2f3cfbf56cf5a92a86108c8eed401"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/zircote/swagger-php/zipball/0ca908380414596f5ed3a7ad33a04abb4cffe613",
"reference": "0ca908380414596f5ed3a7ad33a04abb4cffe613",
"url": "https://api.github.com/repos/zircote/swagger-php/zipball/39fcd46e79c2f3cfbf56cf5a92a86108c8eed401",
"reference": "39fcd46e79c2f3cfbf56cf5a92a86108c8eed401",
"shasum": ""
},
"require": {
@@ -10941,7 +10941,7 @@
"psr/log": "^1.1 || ^2.0 || ^3.0",
"symfony/deprecation-contracts": "^2 || ^3",
"symfony/finder": "^5.0 || ^6.0 || ^7.0",
"symfony/yaml": "^5.0 || ^6.0 || ^7.0"
"symfony/yaml": "^5.4 || ^6.0 || ^7.0"
},
"conflict": {
"symfony/process": ">=6, <6.4.14"
@@ -11003,9 +11003,9 @@
],
"support": {
"issues": "https://github.com/zircote/swagger-php/issues",
"source": "https://github.com/zircote/swagger-php/tree/5.5.2"
"source": "https://github.com/zircote/swagger-php/tree/5.7.0"
},
"time": "2025-10-27T04:40:08+00:00"
"time": "2025-11-11T03:41:35+00:00"
}
],
"packages-dev": [
@@ -11839,11 +11839,11 @@
},
{
"name": "phpstan/phpstan",
"version": "2.1.31",
"version": "2.1.32",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/ead89849d879fe203ce9292c6ef5e7e76f867b96",
"reference": "ead89849d879fe203ce9292c6ef5e7e76f867b96",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/e126cad1e30a99b137b8ed75a85a676450ebb227",
"reference": "e126cad1e30a99b137b8ed75a85a676450ebb227",
"shasum": ""
},
"require": {
@@ -11888,7 +11888,7 @@
"type": "github"
}
],
"time": "2025-10-10T14:14:11+00:00"
"time": "2025-11-11T15:18:17+00:00"
},
{
"name": "phpstan/phpstan-deprecation-rules",
@@ -12012,21 +12012,21 @@
},
{
"name": "phpstan/phpstan-phpunit",
"version": "2.0.7",
"version": "2.0.8",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-phpunit.git",
"reference": "9a9b161baee88a5f5c58d816943cff354ff233dc"
"reference": "2fe9fbeceaf76dd1ebaa7bbbb25e2fb5e59db2fe"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/9a9b161baee88a5f5c58d816943cff354ff233dc",
"reference": "9a9b161baee88a5f5c58d816943cff354ff233dc",
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/2fe9fbeceaf76dd1ebaa7bbbb25e2fb5e59db2fe",
"reference": "2fe9fbeceaf76dd1ebaa7bbbb25e2fb5e59db2fe",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
"phpstan/phpstan": "^2.1.18"
"phpstan/phpstan": "^2.1.32"
},
"conflict": {
"phpunit/phpunit": "<7.0"
@@ -12059,9 +12059,9 @@
"description": "PHPUnit extensions and rules for PHPStan",
"support": {
"issues": "https://github.com/phpstan/phpstan-phpunit/issues",
"source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.7"
"source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.8"
},
"time": "2025-07-13T11:31:46+00:00"
"time": "2025-11-11T07:55:22+00:00"
},
{
"name": "phpstan/phpstan-strict-rules",
@@ -14489,16 +14489,16 @@
},
{
"name": "theseer/tokenizer",
"version": "1.2.3",
"version": "1.3.0",
"source": {
"type": "git",
"url": "https://github.com/theseer/tokenizer.git",
"reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2"
"reference": "d74205c497bfbca49f34d4bc4c19c17e22db4ebb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
"reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
"url": "https://api.github.com/repos/theseer/tokenizer/zipball/d74205c497bfbca49f34d4bc4c19c17e22db4ebb",
"reference": "d74205c497bfbca49f34d4bc4c19c17e22db4ebb",
"shasum": ""
},
"require": {
@@ -14527,7 +14527,7 @@
"description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
"support": {
"issues": "https://github.com/theseer/tokenizer/issues",
"source": "https://github.com/theseer/tokenizer/tree/1.2.3"
"source": "https://github.com/theseer/tokenizer/tree/1.3.0"
},
"funding": [
{
@@ -14535,7 +14535,7 @@
"type": "github"
}
],
"time": "2024-03-03T12:36:25+00:00"
"time": "2025-11-13T13:44:09+00:00"
}
],
"aliases": [],

View File

@@ -1,6 +1,7 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
parameters:
env(TRUSTED_PROXIES): ''
env(TRUSTED_HOSTS): ''
framework:
secret: '%env(APP_SECRET)%'
@@ -14,6 +15,7 @@ framework:
enable_attributes: true
trusted_proxies: '%env(string:TRUSTED_PROXIES)%'
trusted_hosts: '%env(string:TRUSTED_HOSTS)%'
exceptions:
App\Validator\ValidationFailedException:
@@ -25,6 +27,7 @@ framework:
# Enables session support. Note that the session will ONLY be started if you read or write from it.
# Remove or comment this section to explicitly disable session support.
session:
name: KIMAI_SESSION
handler_id: App\Security\SessionHandler
cookie_secure: auto
cookie_samesite: lax

View File

@@ -3,20 +3,20 @@ nelmio_api_doc:
models:
use_jms: true
names:
- { alias: CustomerEditForm, type: App\Form\API\CustomerApiEditForm, groups: [Default, Entity, Customer] }
- { alias: CustomerEditForm, type: App\Form\API\CustomerApiEditForm, groups: [Default, Entity, Customer, Customer_Entity] }
- { alias: CustomerEntity, type: App\Entity\Customer, groups: [Default, Entity, Customer, Customer_Entity, Not_Expanded] }
- { alias: Customer, type: App\Entity\Customer, groups: [Default, Not_Expanded] }
- { alias: CustomerRate, type: App\Entity\CustomerRate, groups: [Default, Entity, Customer_Rate] }
- { alias: CustomerRateForm, type: App\Form\API\CustomerRateApiForm, groups: [Default, Entity, Customer_Rate] }
- { alias: CustomerCollection, type: App\Entity\Customer, groups: [Default, Collection, Customer] }
- { alias: ProjectEditForm, type: App\Form\API\ProjectApiEditForm, groups: [Default, Entity, Project] }
- { alias: ProjectEditForm, type: App\Form\API\ProjectApiEditForm, groups: [Default, Entity, Project, Project_Entity] }
- { alias: ProjectEntity, type: App\Entity\Project, groups: [Default, Entity, Project, Project_Entity, Not_Expanded] }
- { alias: Project, type: App\Entity\Project, groups: [Default, Not_Expanded] }
- { alias: ProjectExpanded, type: App\Entity\Project, groups: [Default, Expanded] }
- { alias: ProjectRate, type: App\Entity\ProjectRate, groups: [Default, Entity, Project_Rate] }
- { alias: ProjectRateForm, type: App\Form\API\ProjectRateApiForm, groups: [Default, Entity, Project_Rate] }
- { alias: ProjectCollection, type: App\Entity\Project, groups: [Default, Collection, Project] }
- { alias: ActivityEditForm, type: App\Form\API\ActivityApiEditForm, groups: [Default, Entity, Activity] }
- { alias: ActivityEditForm, type: App\Form\API\ActivityApiEditForm, groups: [Default, Entity, Activity, Activity_Entity] }
- { alias: ActivityEntity, type: App\Entity\Activity, groups: [Default, Entity, Activity, Activity_Entity, Not_Expanded] }
- { alias: Activity, type: App\Entity\Activity, groups: [Default, Not_Expanded] }
- { alias: ActivityExpanded, type: App\Entity\Activity, groups: [Default, Expanded] }

View File

@@ -42,6 +42,7 @@ security:
- App\Saml\SamlAuthenticator
remember_me:
name: KIMAI_REMEMBER
secret: '%kernel.secret%'
lifetime: 604800
path: /

View File

@@ -41,7 +41,6 @@ parameters:
symfony:
containerXmlPath: %rootDir%/../../../var/cache/dev/App_KernelDevDebugContainer.xml
ignoreErrors:
- identifier: throws.unusedType
- identifier: offsetAccess.notFound
- '#^Method .*\(\) has parameter \$builder with generic interface Symfony\\Component\\Form\\FormBuilderInterface but does not specify its types\: TData$#'
@@ -655,21 +654,11 @@ parameters:
count: 1
path: src/Controller/ActivityController.php
-
message: "#^Parameter \\#2 \\$replace of method App\\\\Repository\\\\ActivityRepository\\:\\:deleteActivity\\(\\) expects App\\\\Entity\\\\Activity\\|null, mixed given\\.$#"
count: 1
path: src/Controller/ActivityController.php
-
message: "#^Parameter \\#1 \\$name of class App\\\\Entity\\\\Team constructor expects string, string\\|null given\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Parameter \\#2 \\$replace of method App\\\\Repository\\\\CustomerRepository\\:\\:deleteCustomer\\(\\) expects App\\\\Entity\\\\Customer\\|null, mixed given\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
count: 2
@@ -795,11 +784,6 @@ parameters:
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Parameter \\#2 \\$replace of method App\\\\Repository\\\\ProjectRepository\\:\\:deleteProject\\(\\) expects App\\\\Entity\\\\Project\\|null, mixed given\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Cannot call method format\\(\\) on DateTime\\|null\\.$#"
count: 1
@@ -980,21 +964,11 @@ parameters:
count: 1
path: src/DataFixtures/TeamFixtures.php
-
message: "#^Cannot call method setTimestamp\\(\\) on DateTime\\|null\\.$#"
count: 1
path: src/DataFixtures/TimesheetFixtures.php
-
message: "#^Cannot clone DateTime\\|null\\.$#"
count: 1
path: src/DataFixtures/TimesheetFixtures.php
-
message: "#^Parameter \\#1 \\$begin of method App\\\\Entity\\\\Timesheet\\:\\:setBegin\\(\\) expects DateTime, DateTime\\|null given\\.$#"
count: 1
path: src/DataFixtures/TimesheetFixtures.php
-
message: "#^Parameter \\#1 \\$user of method App\\\\DataFixtures\\\\TimesheetFixtures\\:\\:createTimesheetEntry\\(\\) expects App\\\\Entity\\\\User, App\\\\Entity\\\\User\\|null given\\.$#"
count: 2
@@ -1910,11 +1884,6 @@ parameters:
count: 1
path: src/Form/TimesheetEditForm.php
-
message: "#^Cannot call method modify\\(\\) on DateTime\\|null\\.$#"
count: 1
path: src/Form/TimesheetEditForm.php
-
message: "#^Cannot clone DateTime\\|null\\.$#"
count: 1
@@ -2685,11 +2654,6 @@ parameters:
count: 1
path: src/Form/Type/MetaFieldsCollectionType.php
-
message: "#^Cannot call method modify\\(\\) on DateTime\\|null\\.$#"
count: 1
path: src/Form/Type/QuickEntryTimesheetType.php
-
message: "#^Cannot cast mixed to int\\.$#"
count: 2
@@ -3310,11 +3274,6 @@ parameters:
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Cannot call method add\\(\\) on DateTime\\|null\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Cannot call method getCustomer\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
count: 1
@@ -3475,71 +3434,36 @@ parameters:
count: 2
path: src/Timesheet/Rounding/CeilRounding.php
-
message: "#^Cannot call method setTimestamp\\(\\) on DateTime\\|null\\.$#"
count: 2
path: src/Timesheet/Rounding/CeilRounding.php
-
message: "#^Cannot clone DateTime\\|null\\.$#"
count: 2
path: src/Timesheet/Rounding/CeilRounding.php
-
message: "#^Parameter \\#1 \\$begin of method App\\\\Entity\\\\Timesheet\\:\\:setBegin\\(\\) expects DateTime, DateTime\\|null given\\.$#"
count: 1
path: src/Timesheet/Rounding/CeilRounding.php
-
message: "#^Cannot call method getTimestamp\\(\\) on DateTime\\|null\\.$#"
count: 2
path: src/Timesheet/Rounding/ClosestRounding.php
-
message: "#^Cannot call method setTimestamp\\(\\) on DateTime\\|null\\.$#"
count: 4
path: src/Timesheet/Rounding/ClosestRounding.php
-
message: "#^Cannot clone DateTime\\|null\\.$#"
count: 2
path: src/Timesheet/Rounding/ClosestRounding.php
-
message: "#^Parameter \\#1 \\$begin of method App\\\\Entity\\\\Timesheet\\:\\:setBegin\\(\\) expects DateTime, DateTime\\|null given\\.$#"
count: 1
path: src/Timesheet/Rounding/ClosestRounding.php
-
message: "#^Cannot call method getTimestamp\\(\\) on DateTime\\|null\\.$#"
count: 2
path: src/Timesheet/Rounding/FloorRounding.php
-
message: "#^Cannot call method setTimestamp\\(\\) on DateTime\\|null\\.$#"
count: 2
path: src/Timesheet/Rounding/FloorRounding.php
-
message: "#^Cannot clone DateTime\\|null\\.$#"
count: 2
path: src/Timesheet/Rounding/FloorRounding.php
-
message: "#^Parameter \\#1 \\$begin of method App\\\\Entity\\\\Timesheet\\:\\:setBegin\\(\\) expects DateTime, DateTime\\|null given\\.$#"
count: 1
path: src/Timesheet/Rounding/FloorRounding.php
-
message: "#^Cannot call method getTimezone\\(\\) on App\\\\Entity\\\\User\\|null\\.$#"
count: 2
path: src/Timesheet/TimesheetService.php
-
message: "#^Cannot call method getTimezone\\(\\) on DateTime\\|null\\.$#"
count: 1
path: src/Timesheet/TimesheetService.php
-
message: "#^Cannot clone DateTime\\|null\\.$#"
count: 1
@@ -3565,11 +3489,6 @@ parameters:
count: 1
path: src/Timesheet/TimesheetService.php
-
message: "#^Parameter \\#1 \\$begin of method App\\\\Entity\\\\Timesheet\\:\\:setBegin\\(\\) expects DateTime, DateTime\\|null given\\.$#"
count: 1
path: src/Timesheet/TimesheetService.php
-
message: "#^Method App\\\\Timesheet\\\\TimesheetStatisticService\\:\\:getDailyStatisticsGrouped\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -3710,11 +3629,6 @@ parameters:
count: 1
path: src/Twig/Runtime/WidgetExtension.php
-
message: "#^Method App\\\\User\\\\LoginManager\\:\\:logInUser\\(\\) has no return type specified\\.$#"
count: 1
path: src/User/LoginManager.php
-
message: "#^Method App\\\\User\\\\UserService\\:\\:updateUser\\(\\) has parameter \\$groups with no value type specified in iterable type array\\.$#"
count: 1

View File

@@ -12,8 +12,6 @@ namespace App\API;
use App\Activity\ActivityService;
use App\Entity\Activity;
use App\Entity\ActivityRate;
use App\Entity\User;
use App\Event\ActivityMetaDefinitionEvent;
use App\Form\API\ActivityApiEditForm;
use App\Form\API\ActivityRateApiForm;
use App\Repository\ActivityRateRepository;
@@ -26,7 +24,6 @@ use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -39,14 +36,12 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
final class ActivityController extends BaseApiController
{
public const GROUPS_ENTITY = ['Default', 'Entity', 'Activity', 'Activity_Entity'];
public const GROUPS_FORM = ['Default', 'Entity', 'Activity'];
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Activity'];
public const GROUPS_RATE = ['Default', 'Entity', 'Activity_Rate'];
public function __construct(
private readonly ViewHandlerInterface $viewHandler,
private readonly ActivityRepository $repository,
private readonly EventDispatcherInterface $dispatcher,
private readonly ActivityRateRepository $activityRateRepository,
private readonly ActivityService $activityService
) {
@@ -63,25 +58,12 @@ final class ActivityController extends BaseApiController
#[Rest\QueryParam(name: 'globals', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Use if you want to fetch only global activities. Allowed values: 0|1 (default: 0 for false)')]
#[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)')]
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
#[Rest\QueryParam(name: 'term', description: 'Free search term', nullable: true)]
public function cgetAction(ParamFetcherInterface $paramFetcher, ProjectRepository $projectRepository): Response
{
/** @var User $user */
$user = $this->getUser();
$query = new ActivityQuery();
$query->loadTeams();
$query->setCurrentUser($user);
$order = $paramFetcher->get('order');
if (\is_string($order) && $order !== '') {
$query->setOrder($order);
}
$orderBy = $paramFetcher->get('orderBy');
if (\is_string($orderBy) && $orderBy !== '') {
$query->setOrderBy($orderBy);
}
$this->prepareQuery($query, $paramFetcher);
$globals = $paramFetcher->get('globals');
if (\is_string($globals) && ($globals === 'true' || $globals === '1')) {
@@ -113,7 +95,6 @@ final class ActivityController extends BaseApiController
$query->setSearchTerm(new SearchTerm($term));
}
$query->setIsApiCall(true);
$data = $this->repository->getActivitiesForQuery($query);
$view = new View($data, 200);
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
@@ -148,10 +129,7 @@ final class ActivityController extends BaseApiController
throw $this->createAccessDeniedException('User cannot create activities');
}
$activity = new Activity();
$event = new ActivityMetaDefinitionEvent($activity);
$this->dispatcher->dispatch($event);
$activity = $this->activityService->createNewActivity();
$form = $this->createForm(ActivityApiEditForm::class, $activity, [
'include_budget' => $this->isGranted('budget', $activity),
@@ -170,7 +148,7 @@ final class ActivityController extends BaseApiController
}
$view = new View($form);
$view->getContext()->setGroups(self::GROUPS_FORM);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
return $this->viewHandler->handle($view);
}
@@ -185,8 +163,7 @@ final class ActivityController extends BaseApiController
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_activity', requirements: ['id' => '\d+'])]
public function patchAction(Request $request, Activity $activity): Response
{
$event = new ActivityMetaDefinitionEvent($activity);
$this->dispatcher->dispatch($event);
$this->activityService->loadMetaFields($activity);
$form = $this->createForm(ActivityApiEditForm::class, $activity, [
'include_budget' => $this->isGranted('budget', $activity),
@@ -198,7 +175,7 @@ final class ActivityController extends BaseApiController
if (false === $form->isValid()) {
$view = new View($form, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_FORM);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
return $this->viewHandler->handle($view);
}
@@ -241,8 +218,7 @@ final class ActivityController extends BaseApiController
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
public function metaAction(Activity $activity, ParamFetcherInterface $paramFetcher): Response
{
$event = new ActivityMetaDefinitionEvent($activity);
$this->dispatcher->dispatch($event);
$this->activityService->loadMetaFields($activity);
$name = $paramFetcher->get('name');
$value = $paramFetcher->get('value');

View File

@@ -21,6 +21,7 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
abstract class BaseApiController extends AbstractController
{
public const MAX_PAGE_SIZE = 500;
public const DATE_ONLY_FORMAT = 'yyyy-MM-dd';
public const DATE_FORMAT = DateTimeType::HTML5_FORMAT;
public const DATE_FORMAT_PHP = 'Y-m-d\TH:i:s';
@@ -84,8 +85,8 @@ abstract class BaseApiController extends AbstractController
$size = $all['size'];
if (is_numeric($size)) {
$size = (int) $size;
if ($size < 1 || $size > 500) {
throw new BadRequestHttpException('Size must be between 1 and 500');
if ($size < 1 || $size > self::MAX_PAGE_SIZE) {
throw new BadRequestHttpException('Size must be between 1 and ' . self::MAX_PAGE_SIZE);
}
$query->setPageSize($size);
}

View File

@@ -23,16 +23,12 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Default')]
final class ConfigurationController extends BaseApiController
{
public function __construct(private readonly ViewHandlerInterface $viewHandler)
{
}
/**
* Fetch timesheet configuration
*/
#[OA\Response(response: 200, description: 'Returns the instance specific timesheet configuration', content: new OA\JsonContent(ref: new Model(type: TimesheetConfig::class)))]
#[Route(path: '/config/timesheet', methods: ['GET'])]
public function timesheetConfigAction(SystemConfiguration $configuration): Response
public function timesheetConfigAction(SystemConfiguration $configuration, ViewHandlerInterface $viewHandler): Response
{
$model = new TimesheetConfig();
$model->setTrackingMode($configuration->getTimesheetTrackingMode());
@@ -44,7 +40,7 @@ final class ConfigurationController extends BaseApiController
$view = new View($model, 200);
$view->getContext()->setGroups(['Default', 'Config']);
return $this->viewHandler->handle($view);
return $viewHandler->handle($view);
}
/**
@@ -52,11 +48,11 @@ final class ConfigurationController extends BaseApiController
*/
#[OA\Response(response: 200, description: 'Returns the configured color codes and names', content: new OA\JsonContent(type: 'object', example: ['Red' => '#ff0000'], additionalProperties: new OA\AdditionalProperties(type: 'string')))]
#[Route(path: '/config/colors', methods: ['GET'])]
public function colorConfigAction(SystemConfiguration $configuration): Response
public function colorConfigAction(SystemConfiguration $configuration, ViewHandlerInterface $viewHandler): Response
{
$view = new View($configuration->getThemeColors(), 200);
$view->getContext()->setGroups(['Default']);
return $this->viewHandler->handle($view);
return $viewHandler->handle($view);
}
}

View File

@@ -13,7 +13,6 @@ use App\Customer\CustomerService;
use App\Entity\Customer;
use App\Entity\CustomerRate;
use App\Entity\User;
use App\Event\CustomerMetaDefinitionEvent;
use App\Form\API\CustomerApiEditForm;
use App\Form\API\CustomerRateApiForm;
use App\Repository\CustomerRateRepository;
@@ -25,7 +24,6 @@ use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -38,14 +36,12 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
final class CustomerController extends BaseApiController
{
public const GROUPS_ENTITY = ['Default', 'Entity', 'Customer', 'Customer_Entity'];
public const GROUPS_FORM = ['Default', 'Entity', 'Customer'];
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Customer'];
public const GROUPS_RATE = ['Default', 'Entity', 'Customer_Rate'];
public function __construct(
private readonly ViewHandlerInterface $viewHandler,
private readonly CustomerRepository $repository,
private readonly EventDispatcherInterface $dispatcher,
private readonly CustomerRateRepository $customerRateRepository,
private readonly CustomerService $customerService,
) {
@@ -59,7 +55,7 @@ final class CustomerController extends BaseApiController
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter customers: 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 (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)')]
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
#[Rest\QueryParam(name: 'term', description: 'Free search term', nullable: true)]
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{
/** @var User $user */
@@ -125,9 +121,6 @@ final class CustomerController extends BaseApiController
$customer = $customerService->createNewCustomer('');
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch($event);
$form = $this->createForm(CustomerApiEditForm::class, $customer, [
'include_budget' => $this->isGranted('budget', $customer),
'include_time' => $this->isGranted('time', $customer),
@@ -145,7 +138,7 @@ final class CustomerController extends BaseApiController
}
$view = new View($form);
$view->getContext()->setGroups(self::GROUPS_FORM);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
return $this->viewHandler->handle($view);
}
@@ -160,8 +153,7 @@ final class CustomerController extends BaseApiController
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_customer', requirements: ['id' => '\d+'])]
public function patchAction(Request $request, Customer $customer): Response
{
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch($event);
$this->customerService->loadMetaFields($customer);
$form = $this->createForm(CustomerApiEditForm::class, $customer, [
'include_budget' => $this->isGranted('budget', $customer),
@@ -173,7 +165,7 @@ final class CustomerController extends BaseApiController
if (false === $form->isValid()) {
$view = new View($form, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_FORM);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
return $this->viewHandler->handle($view);
}
@@ -216,8 +208,7 @@ final class CustomerController extends BaseApiController
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
public function metaAction(Customer $customer, ParamFetcherInterface $paramFetcher): Response
{
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch($event);
$this->customerService->loadMetaFields($customer);
$name = $paramFetcher->get('name');
$value = $paramFetcher->get('value');

View File

@@ -23,12 +23,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Export')]
final class ExportController extends BaseApiController
{
public function __construct(
private readonly ViewHandlerInterface $viewHandler,
private readonly ExportTemplateRepository $repository,
) {
}
/**
* Delete export template
*/
@@ -36,12 +30,12 @@ final class ExportController extends BaseApiController
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete export template')], x: ['internal' => true])]
#[OA\Parameter(name: 'id', description: 'Export template ID to delete', in: 'path', required: true)]
#[Route(path: '/{id}', name: 'delete_export_template', requirements: ['id' => '\d+'], methods: ['DELETE'])]
public function deleteTemplate(ExportTemplate $exportTemplate): Response
public function deleteTemplate(ExportTemplate $exportTemplate, ExportTemplateRepository $repository, ViewHandlerInterface $viewHandler): Response
{
$this->repository->removeExportTemplate($exportTemplate);
$repository->removeExportTemplate($exportTemplate);
$view = new View(null, Response::HTTP_NO_CONTENT);
return $this->viewHandler->handle($view);
return $viewHandler->handle($view);
}
}

View File

@@ -12,7 +12,6 @@ namespace App\API;
use App\Entity\Project;
use App\Entity\ProjectRate;
use App\Entity\User;
use App\Event\ProjectMetaDefinitionEvent;
use App\Form\API\ProjectApiEditForm;
use App\Form\API\ProjectRateApiForm;
use App\Project\ProjectService;
@@ -26,7 +25,6 @@ use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -40,14 +38,12 @@ use Symfony\Component\Validator\Constraints;
final class ProjectController extends BaseApiController
{
public const GROUPS_ENTITY = ['Default', 'Entity', 'Project', 'Project_Entity'];
public const GROUPS_FORM = ['Default', 'Entity', 'Project'];
public const GROUPS_COLLECTION = ['Default', 'Collection', 'Project'];
public const GROUPS_RATE = ['Default', 'Entity', 'Project_Rate'];
public function __construct(
private readonly ViewHandlerInterface $viewHandler,
private readonly ProjectRepository $repository,
private readonly EventDispatcherInterface $dispatcher,
private readonly ProjectRateRepository $projectRateRepository,
private readonly ProjectService $projectService
) {
@@ -67,7 +63,7 @@ final class ProjectController extends BaseApiController
#[Rest\QueryParam(name: 'globalActivities', requirements: '0|1', strict: true, nullable: true, description: "If given, filters projects by their 'global activity' support. Allowed values: 1 (supports global activities) and 0 (without global activities) (default: all)")]
#[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|customer', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name, customer (default: name)')]
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
#[Rest\QueryParam(name: 'term', description: 'Free search term', nullable: true)]
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository): Response
{
/** @var User $user */
@@ -197,7 +193,7 @@ final class ProjectController extends BaseApiController
}
$view = new View($form);
$view->getContext()->setGroups(self::GROUPS_FORM);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
return $this->viewHandler->handle($view);
}
@@ -212,8 +208,7 @@ final class ProjectController extends BaseApiController
#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_project', requirements: ['id' => '\d+'])]
public function patchAction(Request $request, Project $project): Response
{
$event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch($event);
$this->projectService->loadMetaFields($project);
$form = $this->createForm(ProjectApiEditForm::class, $project, [
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
@@ -227,7 +222,7 @@ final class ProjectController extends BaseApiController
if (false === $form->isValid()) {
$view = new View($form, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_FORM);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
return $this->viewHandler->handle($view);
}
@@ -270,8 +265,7 @@ final class ProjectController extends BaseApiController
#[Rest\RequestParam(name: 'value', strict: true, nullable: false, description: 'The meta-field value')]
public function metaAction(Project $project, ParamFetcherInterface $paramFetcher): Response
{
$event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch($event);
$this->projectService->loadMetaFields($project);
$name = $paramFetcher->get('name');
$value = $paramFetcher->get('value');

View File

@@ -92,7 +92,7 @@ final class TimesheetController extends BaseApiController
#[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)')]
#[Rest\QueryParam(name: 'billable', requirements: '0|1', strict: true, nullable: true, description: 'Filter for non-/billable records. Allowed values: 0=non-billable, 1=billable (default: all)')]
#[Rest\QueryParam(name: 'full', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Allows to fetch full objects including subresources. Allowed values: 0|1|false|true (default: false)')]
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
#[Rest\QueryParam(name: 'term', description: 'Free search term', nullable: true)]
#[Rest\QueryParam(name: 'modified_after', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records changed after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository, ProjectRepository $projectRepository, ActivityRepository $activityRepository, UserRepository $userRepository): Response
{

View File

@@ -58,7 +58,7 @@ final class UserController extends BaseApiController
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter users: 1=visible, 2=hidden, 3=all')]
#[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)')]
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
#[Rest\QueryParam(name: 'term', description: 'Free search term', nullable: true)]
#[Rest\QueryParam(name: 'full', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Allows to fetch full objects including subresources. Allowed values: 0|1|false|true (default: false)')]
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{

View File

@@ -40,6 +40,11 @@ class ActivityService
{
}
public function loadMetaFields(Activity $activity): void
{
$this->dispatcher->dispatch(new ActivityMetaDefinitionEvent($activity));
}
public function createNewActivity(?Project $project = null): Activity
{
$activity = new Activity();
@@ -49,7 +54,7 @@ class ActivityService
$activity->setProject($project);
}
$this->dispatcher->dispatch(new ActivityMetaDefinitionEvent($activity));
$this->loadMetaFields($activity);
$this->dispatcher->dispatch(new ActivityCreateEvent($activity));
return $activity;
@@ -82,10 +87,10 @@ class ActivityService
return $activity;
}
public function deleteActivity(Activity $activity): void
public function deleteActivity(Activity $activity, ?Activity $replace = null): void
{
$this->dispatcher->dispatch(new ActivityDeleteEvent($activity));
$this->repository->deleteActivity($activity);
$this->dispatcher->dispatch(new ActivityDeleteEvent($activity, $replace));
$this->repository->deleteActivity($activity, $replace);
}
/**

View File

@@ -100,7 +100,7 @@ final class ResetTestCommand extends AbstractResetCommand
$user2->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y');
$user2->setEnabled(true);
$user2->setRoles(['ROLE_USER']);
$user2->setUserIdentifier('john_user');
$user2->setUserIdentifier(UserFixtures::USERNAME_USER);
$user2->setEmail('john_user@example.com');
$token2 = new AccessToken($user2, UserFixtures::DEFAULT_API_TOKEN . '_user');
$token2->setName('Test fixture');
@@ -118,41 +118,41 @@ final class ResetTestCommand extends AbstractResetCommand
$token3 = new AccessToken($user3, UserFixtures::DEFAULT_API_TOKEN . '_inactive');
$token3->setName('Test fixture');
$user4 = new User();
$user4->setPreferenceValue(UserPreference::HOURLY_RATE, 35);
$user4->setAlias('Tony Maier');
$user4->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$user4->setTitle('Head of Development');
$user4->setAvatar('https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg');
$user4->setEnabled(true);
$user4->setRoles(['ROLE_TEAMLEAD']);
$user4->setUserIdentifier('tony_teamlead');
$user4->setEmail('tony_teamlead@example.com');
$token4 = new AccessToken($user4, UserFixtures::DEFAULT_API_TOKEN . '_teamlead');
$userTeamlead = new User();
$userTeamlead->setPreferenceValue(UserPreference::HOURLY_RATE, 35);
$userTeamlead->setAlias('Tony Maier');
$userTeamlead->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$userTeamlead->setTitle('Head of Development');
$userTeamlead->setAvatar('https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg');
$userTeamlead->setEnabled(true);
$userTeamlead->setRoles(['ROLE_TEAMLEAD']);
$userTeamlead->setUserIdentifier(UserFixtures::USERNAME_TEAMLEAD);
$userTeamlead->setEmail('tony_teamlead@example.com');
$token4 = new AccessToken($userTeamlead, UserFixtures::DEFAULT_API_TOKEN . '_teamlead');
$token4->setName('Test fixture');
$user5 = new User();
$user5->setPreferenceValue(UserPreference::HOURLY_RATE, 81);
$user5->setAlias('Anna Smith');
$user5->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$user5->setTitle('Administrator');
$user5->setEnabled(true);
$user5->setRoles(['ROLE_ADMIN']);
$user5->setUserIdentifier('anna_admin');
$user5->setEmail('anna_admin@example.com');
$token5 = new AccessToken($user5, UserFixtures::DEFAULT_API_TOKEN . '_admin');
$userAdmin = new User();
$userAdmin->setPreferenceValue(UserPreference::HOURLY_RATE, 81);
$userAdmin->setAlias('Anna Smith');
$userAdmin->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$userAdmin->setTitle('Administrator');
$userAdmin->setEnabled(true);
$userAdmin->setRoles(['ROLE_ADMIN']);
$userAdmin->setUserIdentifier(UserFixtures::USERNAME_ADMIN);
$userAdmin->setEmail('anna_admin@example.com');
$token5 = new AccessToken($userAdmin, UserFixtures::DEFAULT_API_TOKEN . '_admin');
$token5->setName('Test fixture');
$user6 = new User();
$user6->setPreferenceValue(UserPreference::HOURLY_RATE, 46);
$user6->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$user6->setTitle('Super Administrator');
$user6->setAvatar('/bundles/avanzuadmintheme/img/avatar.png');
$user6->setEnabled(true);
$user6->setRoles(['ROLE_SUPER_ADMIN']);
$user6->setUserIdentifier('susan_super');
$user6->setEmail('susan_super@example.com');
$token6 = new AccessToken($user6, UserFixtures::DEFAULT_API_TOKEN . '_super');
$userSuperAdmin = new User();
$userSuperAdmin->setPreferenceValue(UserPreference::HOURLY_RATE, 46);
$userSuperAdmin->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
$userSuperAdmin->setTitle('Super Administrator');
$userSuperAdmin->setAvatar('/bundles/avanzuadmintheme/img/avatar.png');
$userSuperAdmin->setEnabled(true);
$userSuperAdmin->setRoles(['ROLE_SUPER_ADMIN']);
$userSuperAdmin->setUserIdentifier(UserFixtures::USERNAME_SUPER_ADMIN);
$userSuperAdmin->setEmail('susan_super@example.com');
$token6 = new AccessToken($userSuperAdmin, UserFixtures::DEFAULT_API_TOKEN . '_super');
$token6->setName('Test fixture');
$user7 = new User();
@@ -180,9 +180,9 @@ final class ResetTestCommand extends AbstractResetCommand
[$user1, $token1],
[$user2, $token2],
[$user3, $token3],
[$user4, $token4],
[$user5, $token5],
[$user6, $token6],
[$userTeamlead, $token4],
[$userAdmin, $token5],
[$userSuperAdmin, $token6],
[$user7, $token7],
[$user8, $token8],
];

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.41.0';
public const VERSION = '2.43.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 24100;
public const VERSION_ID = 24300;
/**
* The software name
*/

View File

@@ -14,11 +14,9 @@ use App\Activity\ActivityStatisticService;
use App\Configuration\SystemConfiguration;
use App\Entity\Activity;
use App\Entity\ActivityRate;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Project;
use App\Entity\Team;
use App\Event\ActivityDetailControllerEvent;
use App\Event\ActivityMetaDefinitionEvent;
use App\Event\ActivityMetaDisplayEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
@@ -51,19 +49,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/admin/activity')]
final class ActivityController extends AbstractController
{
public function __construct(
private readonly ActivityRepository $repository,
private readonly SystemConfiguration $configuration,
private readonly EventDispatcherInterface $dispatcher,
private readonly ActivityService $activityService
)
public function __construct(private readonly ActivityRepository $repository)
{
}
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_activity', methods: ['GET'])]
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_activity_paginated', methods: ['GET'])]
#[IsGranted(new Expression("is_granted('listing', 'activity')"))]
public function indexAction(int $page, Request $request): Response
public function indexAction(int $page, Request $request, EventDispatcherInterface $dispatcher, SystemConfiguration $configuration): Response
{
$query = new ActivityQuery();
$query->loadTeams();
@@ -76,7 +69,10 @@ final class ActivityController extends AbstractController
}
$entries = $this->repository->getPagerfantaForQuery($query);
$metaColumns = $this->findMetaColumns($query);
$event = new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::ACTIVITY);
$dispatcher->dispatch($event);
$metaColumns = $event->getFields();
$table = new DataTable('activity_admin', $query);
$table->setPagination($entries);
@@ -114,29 +110,16 @@ final class ActivityController extends AbstractController
'page_setup' => $page,
'dataTable' => $table,
'metaColumns' => $metaColumns,
'defaultCurrency' => $this->configuration->getCustomerDefaultCurrency(),
'defaultCurrency' => $configuration->getCustomerDefaultCurrency(),
'now' => $this->getDateTimeFactory()->createDateTime(),
]);
}
/**
* @param ActivityQuery $query
* @return MetaTableTypeInterface[]
*/
private function findMetaColumns(ActivityQuery $query): array
{
$event = new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::ACTIVITY);
$this->dispatcher->dispatch($event);
return $event->getFields();
}
#[Route(path: '/{id}/details', name: 'activity_details', methods: ['GET', 'POST'])]
#[IsGranted('view', 'activity')]
public function detailsAction(Activity $activity, TeamRepository $teamRepository, ActivityRateRepository $rateRepository, ActivityStatisticService $statisticService): Response
public function detailsAction(Activity $activity, TeamRepository $teamRepository, ActivityRateRepository $rateRepository, ActivityStatisticService $statisticService, ActivityService $activityService, EventDispatcherInterface $dispatcher): Response
{
$event = new ActivityMetaDefinitionEvent($activity);
$this->dispatcher->dispatch($event);
$activityService->loadMetaFields($activity);
$stats = null;
$rates = [];
@@ -179,7 +162,7 @@ final class ActivityController extends AbstractController
// additional boxes by plugins
$event = new ActivityDetailControllerEvent($activity);
$this->dispatcher->dispatch($event);
$dispatcher->dispatch($event);
$boxes = $event->getController();
$page = $this->createPageSetup();
@@ -247,31 +230,28 @@ final class ActivityController extends AbstractController
#[Route(path: '/create/{project}', name: 'admin_activity_create_with_project', methods: ['GET', 'POST'])]
#[IsGranted('create_activity')]
public function createWithProjectAction(Request $request, Project $project): Response
public function createWithProjectAction(Project $project, Request $request, ActivityService $activityService, SystemConfiguration $configuration): Response
{
return $this->createActivity($request, $project);
return $this->createActivity($request, $activityService, $configuration, $project);
}
#[Route(path: '/create', name: 'admin_activity_create', methods: ['GET', 'POST'])]
#[IsGranted('create_activity')]
public function createAction(Request $request): Response
public function createAction(Request $request, ActivityService $activityService, SystemConfiguration $configuration): Response
{
return $this->createActivity($request, null);
return $this->createActivity($request, $activityService, $configuration, null);
}
private function createActivity(Request $request, ?Project $project = null): Response
private function createActivity(Request $request, ActivityService $activityService, SystemConfiguration $configuration, ?Project $project = null): Response
{
$activity = $this->activityService->createNewActivity($project);
$activity = $activityService->createNewActivity($project);
$event = new ActivityMetaDefinitionEvent($activity);
$this->dispatcher->dispatch($event);
$editForm = $this->createEditForm($activity);
$editForm = $this->createEditForm($activity, $configuration);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->activityService->saveActivity($activity);
$activityService->saveActivity($activity);
$this->flashSuccess('action.update.success');
return $this->redirectToRouteAfterCreate('activity_details', ['id' => $activity->getId()]);
@@ -289,7 +269,7 @@ final class ActivityController extends AbstractController
#[Route(path: '/{id}/permissions', name: 'admin_activity_permissions', methods: ['GET', 'POST'])]
#[IsGranted('permissions', 'activity')]
public function teamPermissionsAction(Activity $activity, Request $request): Response
public function teamPermissionsAction(Activity $activity, Request $request, ActivityService $activityService): Response
{
$form = $this->createForm(ActivityTeamPermissionForm::class, $activity, [
'action' => $this->generateUrl('admin_activity_permissions', ['id' => $activity->getId()]),
@@ -300,7 +280,7 @@ final class ActivityController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->activityService->saveActivity($activity);
$activityService->saveActivity($activity);
$this->flashSuccess('action.update.success');
if ($this->isGranted('view', $activity)) {
@@ -345,17 +325,16 @@ final class ActivityController extends AbstractController
#[Route(path: '/{id}/edit', name: 'admin_activity_edit', methods: ['GET', 'POST'])]
#[IsGranted('edit', 'activity')]
public function editAction(Activity $activity, Request $request): Response
public function editAction(Activity $activity, Request $request, ActivityService $activityService, SystemConfiguration $configuration): Response
{
$event = new ActivityMetaDefinitionEvent($activity);
$this->dispatcher->dispatch($event);
$activityService->loadMetaFields($activity);
$editForm = $this->createEditForm($activity);
$editForm = $this->createEditForm($activity, $configuration);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->activityService->saveActivity($activity);
$activityService->saveActivity($activity);
$this->flashSuccess('action.update.success');
if ($this->isGranted('view', $activity)) {
@@ -377,7 +356,7 @@ final class ActivityController extends AbstractController
#[Route(path: '/{id}/delete', name: 'admin_activity_delete', methods: ['GET', 'POST'])]
#[IsGranted('delete', 'activity')]
public function deleteAction(Activity $activity, Request $request, ActivityStatisticService $statisticService): Response
public function deleteAction(Activity $activity, Request $request, ActivityStatisticService $statisticService, ActivityService $activityService): Response
{
$stats = $statisticService->getActivityStatistics($activity);
@@ -404,7 +383,9 @@ final class ActivityController extends AbstractController
if ($deleteForm->isSubmitted() && $deleteForm->isValid()) {
try {
$this->repository->deleteActivity($activity, $deleteForm->get('activity')->getData());
/** @var Activity|null $replace */
$replace = $deleteForm->get('activity')->getData();
$activityService->deleteActivity($activity, $replace);
$this->flashSuccess('action.delete.success');
} catch (Exception $ex) {
$this->flashDeleteException($ex);
@@ -463,9 +444,9 @@ final class ActivityController extends AbstractController
/**
* @return FormInterface<mixed>
*/
private function createEditForm(Activity $activity): FormInterface
private function createEditForm(Activity $activity, SystemConfiguration $configuration): FormInterface
{
$currency = $this->configuration->getCustomerDefaultCurrency();
$currency = $configuration->getCustomerDefaultCurrency();
$url = $this->generateUrl('admin_activity_create');
if ($activity->getProject()?->getId() !== null) {
$url = $this->generateUrl('admin_activity_create_with_project', ['project' => $activity->getProject()->getId()]);

View File

@@ -14,10 +14,8 @@ use App\Customer\CustomerStatisticService;
use App\Entity\Customer;
use App\Entity\CustomerComment;
use App\Entity\CustomerRate;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Team;
use App\Event\CustomerDetailControllerEvent;
use App\Event\CustomerMetaDefinitionEvent;
use App\Event\CustomerMetaDisplayEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
@@ -55,17 +53,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/admin/customer')]
final class CustomerController extends AbstractController
{
public function __construct(
private readonly CustomerRepository $repository,
private readonly EventDispatcherInterface $dispatcher
)
public function __construct(private readonly CustomerRepository $repository)
{
}
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_customer', methods: ['GET'])]
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_customer_paginated', methods: ['GET'])]
#[IsGranted(new Expression("is_granted('listing', 'customer')"))]
public function indexAction(int $page, Request $request): Response
public function indexAction(int $page, Request $request, EventDispatcherInterface $dispatcher): Response
{
$query = new CustomerQuery();
$query->loadTeams();
@@ -78,7 +73,9 @@ final class CustomerController extends AbstractController
}
$entries = $this->repository->getPagerfantaForQuery($query);
$metaColumns = $this->findMetaColumns($query);
$event = new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::CUSTOMER);
$dispatcher->dispatch($event);
$metaColumns = $event->getFields();
$table = new DataTable('customer_admin', $query);
$table->setPagination($entries);
@@ -130,24 +127,13 @@ final class CustomerController extends AbstractController
]);
}
/**
* @return MetaTableTypeInterface[]
*/
private function findMetaColumns(CustomerQuery $query): array
{
$event = new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::CUSTOMER);
$this->dispatcher->dispatch($event);
return $event->getFields();
}
#[Route(path: '/create', name: 'admin_customer_create', methods: ['GET', 'POST'])]
#[IsGranted('create_customer')]
public function createAction(Request $request, CustomerService $customerService): Response
{
$customer = $customerService->createNewCustomer('');
return $this->renderCustomerForm($customer, $request, true, $customerService);
return $this->renderCustomerForm($customer, $request, $customerService);
}
#[Route(path: '/{id}/permissions', name: 'admin_customer_permissions', methods: ['GET', 'POST'])]
@@ -298,10 +284,9 @@ final class CustomerController extends AbstractController
#[Route(path: '/{id}/details', name: 'customer_details', methods: ['GET', 'POST'])]
#[IsGranted('view', 'customer')]
public function detailsAction(Customer $customer, TeamRepository $teamRepository, CustomerRateRepository $rateRepository, CustomerStatisticService $statisticService): Response
public function detailsAction(Customer $customer, TeamRepository $teamRepository, CustomerRateRepository $rateRepository, CustomerStatisticService $statisticService, CustomerService $customerService, EventDispatcherInterface $dispatcher): Response
{
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch($event);
$customerService->loadMetaFields($customer);
$stats = null;
$timezone = null;
@@ -350,7 +335,7 @@ final class CustomerController extends AbstractController
// additional boxes by plugins
$event = new CustomerDetailControllerEvent($customer);
$this->dispatcher->dispatch($event);
$dispatcher->dispatch($event);
$boxes = $event->getController();
$page = $this->createPageSetup();
@@ -424,12 +409,14 @@ final class CustomerController extends AbstractController
#[IsGranted('edit', 'customer')]
public function editAction(Customer $customer, Request $request, CustomerService $customerService): Response
{
return $this->renderCustomerForm($customer, $request, false, $customerService);
$customerService->loadMetaFields($customer);
return $this->renderCustomerForm($customer, $request, $customerService);
}
#[Route(path: '/{id}/delete', name: 'admin_customer_delete', methods: ['GET', 'POST'])]
#[IsGranted('delete', 'customer')]
public function deleteAction(Customer $customer, Request $request, CustomerStatisticService $statisticService): Response
public function deleteAction(Customer $customer, Request $request, CustomerStatisticService $statisticService, CustomerService $customerService): Response
{
$stats = $statisticService->getCustomerStatistics($customer);
@@ -453,7 +440,9 @@ final class CustomerController extends AbstractController
if ($deleteForm->isSubmitted() && $deleteForm->isValid()) {
try {
$this->repository->deleteCustomer($customer, $deleteForm->get('customer')->getData());
/** @var Customer|null $replace */
$replace = $deleteForm->get('customer')->getData();
$customerService->deleteCustomer($customer, $replace);
$this->flashSuccess('action.delete.success');
} catch (\Exception $ex) {
$this->flashDeleteException($ex);
@@ -497,9 +486,22 @@ final class CustomerController extends AbstractController
return $writer->getFileResponse($spreadsheet);
}
private function renderCustomerForm(Customer $customer, Request $request, bool $create, CustomerService $customerService): Response
private function renderCustomerForm(Customer $customer, Request $request, CustomerService $customerService): Response
{
$editForm = $this->createEditForm($customer);
$create = ($customer->getId() === null);
if ($create) {
$url = $this->generateUrl('admin_customer_create');
} else {
$url = $this->generateUrl('admin_customer_edit', ['id' => $customer->getId()]);
}
$editForm = $this->createForm(CustomerEditForm::class, $customer, [
'action' => $url,
'method' => 'POST',
'include_budget' => $this->isGranted('budget', $customer),
'include_time' => $this->isGranted('time', $customer),
]);
$editForm->handleRequest($request);
@@ -557,28 +559,6 @@ final class CustomerController extends AbstractController
]);
}
/**
* @return FormInterface<Customer>
*/
private function createEditForm(Customer $customer): FormInterface
{
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch($event);
if ($customer->getId() === null) {
$url = $this->generateUrl('admin_customer_create');
} else {
$url = $this->generateUrl('admin_customer_edit', ['id' => $customer->getId()]);
}
return $this->createForm(CustomerEditForm::class, $customer, [
'action' => $url,
'method' => 'POST',
'include_budget' => $this->isGranted('budget', $customer),
'include_time' => $this->isGranted('time', $customer),
]);
}
private function createPageSetup(): PageSetup
{
$page = new PageSetup('customers');

View File

@@ -11,13 +11,11 @@ namespace App\Controller;
use App\Configuration\SystemConfiguration;
use App\Entity\Customer;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Project;
use App\Entity\ProjectComment;
use App\Entity\ProjectRate;
use App\Entity\Team;
use App\Event\ProjectDetailControllerEvent;
use App\Event\ProjectMetaDefinitionEvent;
use App\Event\ProjectMetaDisplayEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
@@ -59,19 +57,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/admin/project')]
final class ProjectController extends AbstractController
{
public function __construct(
private readonly ProjectRepository $repository,
private readonly SystemConfiguration $configuration,
private readonly EventDispatcherInterface $dispatcher,
private readonly ProjectService $projectService
)
public function __construct(private readonly ProjectRepository $repository)
{
}
#[Route(path: '/', defaults: ['page' => 1], name: 'admin_project', methods: ['GET'])]
#[Route(path: '/page/{page}', requirements: ['page' => '[1-9]\d*'], name: 'admin_project_paginated', methods: ['GET'])]
#[IsGranted(new Expression("is_granted('listing', 'project')"))]
public function indexAction(int $page, Request $request): Response
public function indexAction(int $page, Request $request, EventDispatcherInterface $dispatcher): Response
{
$query = new ProjectQuery();
$query->loadTeams();
@@ -84,7 +77,9 @@ final class ProjectController extends AbstractController
}
$entries = $this->repository->getPagerfantaForQuery($query);
$metaColumns = $this->findMetaColumns($query);
$event = new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::PROJECT);
$dispatcher->dispatch($event);
$metaColumns = $event->getFields();
$table = new DataTable('project_admin', $query);
$table->setPagination($entries);
@@ -130,21 +125,9 @@ final class ProjectController extends AbstractController
]);
}
/**
* @param ProjectQuery $query
* @return MetaTableTypeInterface[]
*/
private function findMetaColumns(ProjectQuery $query): array
{
$event = new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::PROJECT);
$this->dispatcher->dispatch($event);
return $event->getFields();
}
#[Route(path: '/{id}/permissions', name: 'admin_project_permissions', methods: ['GET', 'POST'])]
#[IsGranted('permissions', 'project')]
public function teamPermissions(Project $project, Request $request): Response
public function teamPermissions(Project $project, Request $request, ProjectService $projectService): Response
{
$form = $this->createForm(ProjectTeamPermissionForm::class, $project, [
'action' => $this->generateUrl('admin_project_permissions', ['id' => $project->getId()]),
@@ -155,7 +138,7 @@ final class ProjectController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->projectService->saveProject($project);
$projectService->saveProject($project);
$this->flashSuccess('action.update.success');
if ($this->isGranted('view', $project)) {
@@ -177,28 +160,28 @@ final class ProjectController extends AbstractController
#[Route(path: '/create/{customer}', name: 'admin_project_create_with_customer', methods: ['GET', 'POST'])]
#[IsGranted('create_project')]
public function createWithCustomerAction(Request $request, Customer $customer): Response
public function createWithCustomerAction(Request $request, Customer $customer, ProjectService $projectService, SystemConfiguration $configuration): Response
{
return $this->createProject($request, $customer);
return $this->createProject($request, $projectService, $configuration, $customer);
}
#[Route(path: '/create', name: 'admin_project_create', methods: ['GET', 'POST'])]
#[IsGranted('create_project')]
public function createAction(Request $request): Response
public function createAction(Request $request, ProjectService $projectService, SystemConfiguration $configuration): Response
{
return $this->createProject($request, null);
return $this->createProject($request, $projectService, $configuration, null);
}
private function createProject(Request $request, ?Customer $customer = null): Response
private function createProject(Request $request, ProjectService $projectService, SystemConfiguration $configuration, ?Customer $customer = null): Response
{
$project = $this->projectService->createNewProject($customer);
$project = $projectService->createNewProject($customer);
$editForm = $this->createEditForm($project);
$editForm = $this->createEditForm($project, $configuration->getCustomerDefaultCurrency());
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->projectService->saveProject($project, new Context($this->getUser()));
$projectService->saveProject($project, new Context($this->getUser()));
$this->flashSuccess('action.update.success');
return $this->redirectToRouteAfterCreate('project_details', ['id' => $project->getId()]);
@@ -330,10 +313,9 @@ final class ProjectController extends AbstractController
#[Route(path: '/{id}/details', name: 'project_details', methods: ['GET', 'POST'])]
#[IsGranted('view', 'project')]
public function detailsAction(Project $project, TeamRepository $teamRepository, ProjectRateRepository $rateRepository, ProjectStatisticService $statisticService, CsrfTokenManagerInterface $csrfTokenManager): Response
public function detailsAction(Project $project, TeamRepository $teamRepository, ProjectRateRepository $rateRepository, ProjectStatisticService $statisticService, ProjectService $projectService, CsrfTokenManagerInterface $csrfTokenManager, EventDispatcherInterface $dispatcher): Response
{
$event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch($event);
$projectService->loadMetaFields($project);
$stats = null;
$defaultTeam = null;
@@ -377,7 +359,7 @@ final class ProjectController extends AbstractController
// additional boxes by plugins
$event = new ProjectDetailControllerEvent($project);
$this->dispatcher->dispatch($event);
$dispatcher->dispatch($event);
$boxes = $event->getController();
$page = $this->createPageSetup();
@@ -448,14 +430,16 @@ final class ProjectController extends AbstractController
#[Route(path: '/{id}/edit', name: 'admin_project_edit', methods: ['GET', 'POST'])]
#[IsGranted('edit', 'project')]
public function editAction(Project $project, Request $request): Response
public function editAction(Project $project, Request $request, ProjectService $projectService, SystemConfiguration $configuration): Response
{
$editForm = $this->createEditForm($project);
$projectService->loadMetaFields($project);
$editForm = $this->createEditForm($project, $configuration->getCustomerDefaultCurrency());
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->projectService->saveProject($project);
$projectService->saveProject($project);
$this->flashSuccess('action.update.success');
if ($this->isGranted('view', $project)) {
@@ -502,7 +486,7 @@ final class ProjectController extends AbstractController
#[Route(path: '/{id}/delete', name: 'admin_project_delete', methods: ['GET', 'POST'])]
#[IsGranted('delete', 'project')]
public function deleteAction(Project $project, Request $request, ProjectStatisticService $statisticService): Response
public function deleteAction(Project $project, Request $request, ProjectStatisticService $statisticService, ProjectService $projectService): Response
{
$stats = $statisticService->getProjectStatistics($project);
@@ -527,7 +511,9 @@ final class ProjectController extends AbstractController
if ($deleteForm->isSubmitted() && $deleteForm->isValid()) {
try {
$this->repository->deleteProject($project, $deleteForm->get('project')->getData());
/** @var Project|null $replace */
$replace = $deleteForm->get('project')->getData();
$projectService->deleteProject($project, $replace);
$this->flashSuccess('action.delete.success');
} catch (\Exception $ex) {
$this->flashDeleteException($ex);
@@ -592,12 +578,8 @@ final class ProjectController extends AbstractController
]);
}
private function createEditForm(Project $project): FormInterface
private function createEditForm(Project $project, string $currency): FormInterface
{
$event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch($event);
$currency = $this->configuration->getCustomerDefaultCurrency();
$url = $this->generateUrl('admin_project_create');
if ($project->getId() !== null) {

View File

@@ -45,6 +45,11 @@ final class CustomerService
return $timezone;
}
public function loadMetaFields(Customer $customer): void
{
$this->dispatcher->dispatch(new CustomerMetaDefinitionEvent($customer));
}
public function createNewCustomer(string $name): Customer
{
$customer = new Customer($name);
@@ -53,7 +58,7 @@ final class CustomerService
$customer->setCurrency($this->configuration->getCustomerDefaultCurrency());
$customer->setNumber($this->calculateNextCustomerNumber());
$this->dispatcher->dispatch(new CustomerMetaDefinitionEvent($customer));
$this->loadMetaFields($customer);
$this->dispatcher->dispatch(new CustomerCreateEvent($customer));
return $customer;
@@ -86,10 +91,10 @@ final class CustomerService
return $customer;
}
public function deleteCustomer(Customer $customer): void
public function deleteCustomer(Customer $customer, ?Customer $replace = null): void
{
$this->dispatcher->dispatch(new CustomerDeleteEvent($customer));
$this->repository->deleteCustomer($customer);
$this->dispatcher->dispatch(new CustomerDeleteEvent($customer, $replace));
$this->repository->deleteCustomer($customer, $replace);
}
/**

View File

@@ -31,7 +31,7 @@ final class InvoiceFixtures extends Fixture
{
$faker = Factory::create('en_US');
/** @var non-empty-array<Customer> $customers */
$customers = $manager->getRepository(Customer::class)->findAll();
$customers = $manager->getRepository(Customer::class)->findBy(['visible' => true]);
foreach ($this->getInvoiceConfigs($faker) as $invoiceConfig) {
// name, title, renderer, calculator, numberGenerator, company, vat, dueDays, address, paymentTerms

View File

@@ -62,10 +62,12 @@ final class TimesheetFixtures extends Fixture implements FixtureGroupInterface
$all = 0;
foreach ($allUser as $user) {
// reload, because the manager might have been cleared
$user = $manager->find(User::class, $user->getId());
// random amount of timesheet entries for every user
$timesheetForUser = rand(self::MIN_TIMESHEETS_PER_USER, self::MAX_TIMESHEETS_PER_USER);
// load on each round, because the manager might have been cleared
$activities = $this->getAllActivities($manager);
$projects = $this->getAllProjects($manager);

View File

@@ -96,7 +96,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Exporter\Expose(label: 'contact')]
private ?string $contact = null;
/**
* Unstructured address, better use the fields: address_line1-3, postcode, city, country
* Unstructured address, better use the fields: addressLine1-3, postcode, city, country
*/
#[ORM\Column(name: 'address', type: Types::TEXT, nullable: true)]
#[Serializer\Expose]

View File

@@ -17,7 +17,7 @@ use Symfony\Contracts\EventDispatcher\Event;
*/
abstract class AbstractActivityEvent extends Event
{
public function __construct(private Activity $activity)
public function __construct(private readonly Activity $activity)
{
}

View File

@@ -17,7 +17,7 @@ use Symfony\Contracts\EventDispatcher\Event;
*/
abstract class AbstractCustomerEvent extends Event
{
public function __construct(private Customer $customer)
public function __construct(private readonly Customer $customer)
{
}

View File

@@ -17,7 +17,7 @@ use Symfony\Contracts\EventDispatcher\Event;
*/
abstract class AbstractProjectEvent extends Event
{
public function __construct(private Project $project)
public function __construct(private readonly Project $project)
{
}

View File

@@ -9,9 +9,19 @@
namespace App\Event;
use App\Entity\Activity;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'activity.deleted', description: 'Triggered right before an activity will be deleted', payload: 'object.getActivity()')]
final class ActivityDeleteEvent extends AbstractActivityEvent
{
public function __construct(Activity $activity, private readonly ?Activity $replacementActivity = null)
{
parent::__construct($activity);
}
public function getReplacementActivity(): ?Activity
{
return $this->replacementActivity;
}
}

View File

@@ -17,7 +17,7 @@ use Symfony\Contracts\EventDispatcher\Event;
*/
final class ActivityMetaDefinitionEvent extends Event
{
public function __construct(private Activity $entity)
public function __construct(private readonly Activity $entity)
{
}

View File

@@ -9,9 +9,19 @@
namespace App\Event;
use App\Entity\Customer;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'customer.deleted', description: 'Triggered right before a customer will be deleted', payload: 'object.getCustomer()')]
final class CustomerDeleteEvent extends AbstractCustomerEvent
{
public function __construct(Customer $customer, private readonly ?Customer $replacementCustomer = null)
{
parent::__construct($customer);
}
public function getReplacementCustomer(): ?Customer
{
return $this->replacementCustomer;
}
}

View File

@@ -17,7 +17,7 @@ use Symfony\Contracts\EventDispatcher\Event;
*/
final class CustomerMetaDefinitionEvent extends Event
{
public function __construct(private Customer $entity)
public function __construct(private readonly Customer $entity)
{
}

View File

@@ -9,9 +9,19 @@
namespace App\Event;
use App\Entity\Project;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'project.deleted', description: 'Triggered right before a project will be deleted', payload: 'object.getProject()')]
final class ProjectDeleteEvent extends AbstractProjectEvent
{
public function __construct(Project $project, private readonly ?Project $replacementProject = null)
{
parent::__construct($project);
}
public function getReplacementProject(): ?Project
{
return $this->replacementProject;
}
}

View File

@@ -17,7 +17,7 @@ use Symfony\Contracts\EventDispatcher\Event;
*/
final class ProjectMetaDefinitionEvent extends Event
{
public function __construct(private Project $entity)
public function __construct(private readonly Project $entity)
{
}

View File

@@ -10,7 +10,22 @@
namespace App\Form\API;
use App\Form\ActivityEditForm;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class ActivityApiEditForm extends ActivityEditForm
final class ActivityApiEditForm extends AbstractType
{
public function getParent(): string
{
return ActivityEditForm::class;
}
public function configureOptions(OptionsResolver $resolver): void
{
// overwritten, so the docs show these fields
$resolver->setDefaults([
'include_budget' => true,
'include_time' => true,
]);
}
}

View File

@@ -10,7 +10,22 @@
namespace App\Form\API;
use App\Form\CustomerEditForm;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class CustomerApiEditForm extends CustomerEditForm
final class CustomerApiEditForm extends AbstractType
{
public function getParent(): string
{
return CustomerEditForm::class;
}
public function configureOptions(OptionsResolver $resolver): void
{
// overwritten, so the docs show these fields
$resolver->setDefaults([
'include_budget' => true,
'include_time' => true,
]);
}
}

View File

@@ -10,7 +10,22 @@
namespace App\Form\API;
use App\Form\ProjectEditForm;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class ProjectApiEditForm extends ProjectEditForm
final class ProjectApiEditForm extends AbstractType
{
public function getParent(): string
{
return ProjectEditForm::class;
}
public function configureOptions(OptionsResolver $resolver): void
{
// overwritten, so the docs show these fields
$resolver->setDefaults([
'include_budget' => true,
'include_time' => true,
]);
}
}

View File

@@ -33,7 +33,7 @@ class CustomerEditForm extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$isNew = false;
$isNew = true;
$hasAddress = false;
if (isset($options['data'])) {
@@ -54,6 +54,9 @@ class CustomerEditForm extends AbstractType
->add('number', TextType::class, [
'label' => 'number',
'required' => false,
'attr' => [
'maxlength' => 50,
],
])
->add('comment', TextareaType::class, [
'label' => 'description',
@@ -83,19 +86,19 @@ class CustomerEditForm extends AbstractType
}
$builder
->add('address_line1', TextType::class, [
->add('addressLine1', TextType::class, [
'label' => 'address',
'required' => false,
])
->add('address_line2', TextType::class, [
->add('addressLine2', TextType::class, [
'label' => false,
'required' => false,
])
->add('address_line3', TextType::class, [
->add('addressLine3', TextType::class, [
'label' => false,
'required' => false,
])
->add('postcode', TextType::class, [
->add('postCode', TextType::class, [
'label' => 'postcode',
'required' => false,
])

View File

@@ -31,6 +31,9 @@ trait EntityFormTrait
if ($showMoney) {
$builder->add('budget', MoneyType::class, [
'documentation' => [
'description' => 'The money budget',
],
'empty_data' => '0.00',
'label' => 'budget',
'required' => false,

View File

@@ -29,7 +29,7 @@ class ProjectEditForm extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$customer = null;
$isNew = false;
$isNew = true;
$options['currency'] = null;
$customerOptions = [];

View File

@@ -31,6 +31,9 @@ final class BudgetType extends AbstractType
'choices' => [
'budgetType_month' => self::TYPE_MONTH,
],
'documentation' => [
'description' => 'The type of budget. Only submit if you want to use a monthly budget.',
],
]);
}

View File

@@ -24,7 +24,7 @@ final class KimaiMailer implements MailerInterface
{
}
public function send(RawMessage $message, Envelope $envelope = null): void
public function send(RawMessage $message, ?Envelope $envelope = null): void
{
if (!$message instanceof Email) {
$email = new Email();
@@ -43,7 +43,7 @@ final class KimaiMailer implements MailerInterface
$this->mailer->send($message);
}
public function sendToUser(User $user, Email $message, Envelope $envelope = null): void
public function sendToUser(User $user, Email $message, ?Envelope $envelope = null): void
{
if (!$user->isEnabled() || $user->getEmail() === null) {
return;

View File

@@ -41,6 +41,11 @@ final class ProjectService
{
}
public function loadMetaFields(Project $project): void
{
$this->dispatcher->dispatch(new ProjectMetaDefinitionEvent($project));
}
public function createNewProject(?Customer $customer = null): Project
{
$project = new Project();
@@ -50,7 +55,7 @@ final class ProjectService
$project->setCustomer($customer);
}
$this->dispatcher->dispatch(new ProjectMetaDefinitionEvent($project));
$this->loadMetaFields($project);
$this->dispatcher->dispatch(new ProjectCreateEvent($project));
return $project;
@@ -90,10 +95,10 @@ final class ProjectService
return $project;
}
public function deleteProject(Project $project): void
public function deleteProject(Project $project, ?Project $replace = null): void
{
$this->dispatcher->dispatch(new ProjectDeleteEvent($project));
$this->repository->deleteProject($project);
$this->dispatcher->dispatch(new ProjectDeleteEvent($project, $replace));
$this->repository->deleteProject($project, $replace);
}
/**

View File

@@ -26,7 +26,6 @@ use App\Repository\Search\SearchHelper;
use App\Utils\Pagination;
use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Andx;
@@ -414,7 +413,7 @@ class ActivityRepository extends EntityRepository
$em->remove($delete);
$em->flush();
$em->commit();
} catch (ORMException $ex) {
} catch (\Exception $ex) {
$em->rollback();
throw $ex;
}

View File

@@ -26,7 +26,6 @@ use App\Repository\Search\SearchHelper;
use App\Utils\Pagination;
use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Andx;
@@ -335,7 +334,7 @@ class CustomerRepository extends EntityRepository
$em->remove($delete);
$em->flush();
$em->commit();
} catch (ORMException $ex) {
} catch (\Exception $ex) {
$em->rollback();
throw $ex;
}

View File

@@ -29,7 +29,6 @@ use DateTime;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Andx;
@@ -415,7 +414,7 @@ class ProjectRepository extends EntityRepository
$em->remove($delete);
$em->flush();
$em->commit();
} catch (ORMException $ex) {
} catch (\Exception $ex) {
$em->rollback();
throw $ex;
}

View File

@@ -11,7 +11,6 @@ namespace App\Repository;
use App\Entity\Role;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Exception\ORMException;
/**
* @extends EntityRepository<Role>
@@ -35,7 +34,7 @@ class RoleRepository extends EntityRepository
$em->remove($role);
$em->flush();
$em->commit();
} catch (ORMException $ex) {
} catch (\Exception $ex) {
$em->rollback();
throw $ex;
}

View File

@@ -22,15 +22,15 @@ use Symfony\Component\Security\Http\Session\SessionAuthenticationStrategyInterfa
final class LoginManager
{
public function __construct(
private TokenStorageInterface $tokenStorage,
private UserChecker $userChecker,
private SessionAuthenticationStrategyInterface $sessionStrategy,
private RequestStack $requestStack,
private EventDispatcherInterface $eventDispatcher,
private readonly TokenStorageInterface $tokenStorage,
private readonly UserChecker $userChecker,
private readonly SessionAuthenticationStrategyInterface $sessionStrategy,
private readonly RequestStack $requestStack,
private readonly EventDispatcherInterface $eventDispatcher,
) {
}
public function logInUser(User $user, Response $response = null)
public function logInUser(User $user, ?Response $response = null): void
{
$this->userChecker->checkPreAuth($user);

View File

@@ -163,7 +163,7 @@
{
id: 'kimaiUserTimeSource',
type: 'timesheet',
url: '{{ path('get_timesheets', {'user': user.id, 'size': 1000, 'full': 'true', 'begin': '__FROM__', 'end': '__TO__'})|raw }}',
url: '{{ path('get_timesheets', {'user': user.id, 'size': constant('\\App\\API\\BaseApiController::MAX_PAGE_SIZE'), 'full': 'true', 'begin': '__FROM__', 'end': '__TO__'})|raw }}',
options: {
color: '{{ config('theme.calendar.background_color') }}',
},

View File

@@ -96,22 +96,22 @@
{% endif %}
<div class="row">
<div class="col-12">
{{ form_row(form.address_line1, {attr: {placeholder: 'address_row'|trans({'%row%': 1})}}) }}
{{ form_row(form.addressLine1, {attr: {placeholder: 'address_row'|trans({'%row%': 1})}}) }}
</div>
</div>
<div class="row">
<div class="col-12">
{{ form_row(form.address_line2, {attr: {placeholder: 'address_row'|trans({'%row%': 2})}}) }}
{{ form_row(form.addressLine2, {attr: {placeholder: 'address_row'|trans({'%row%': 2})}}) }}
</div>
</div>
<div class="row">
<div class="col-12">
{{ form_row(form.address_line3, {attr: {placeholder: 'address_row'|trans({'%row%': 3})}}) }}
{{ form_row(form.addressLine3, {attr: {placeholder: 'address_row'|trans({'%row%': 3})}}) }}
</div>
</div>
<div class="row">
<div class="col-md-3">
{{ form_row(form.postcode) }}
{{ form_row(form.postCode) }}
</div>
<div class="col-md-9">
{{ form_row(form.city) }}

View File

@@ -504,7 +504,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'billable' => 'bool',
'color' => '@string',
'customer' => 'int',
'number' => '@int',
'number' => '@string',
'orderNumber' => '@string',
'globalActivities' => 'bool',
'comment' => '@string',
@@ -521,7 +521,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'billable' => 'bool',
'color' => '@string',
'customer' => ['result' => 'object', 'type' => 'Customer'],
'number' => '@int',
'number' => '@string',
'orderNumber' => '@string',
'globalActivities' => 'bool',
'comment' => '@string',
@@ -537,7 +537,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'visible' => 'bool',
'billable' => 'bool',
'customer' => 'int',
'number' => '@int',
'number' => '@string',
'orderNumber' => '@string',
'color' => '@string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
@@ -557,7 +557,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'visible' => 'bool',
'billable' => 'bool',
'customer' => 'int',
'number' => '@int',
'number' => '@string',
'color' => '@string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'parentTitle' => 'string',
@@ -581,7 +581,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'visible' => 'bool',
'billable' => 'bool',
'project' => '@int',
'number' => '@int',
'number' => '@string',
'color' => '@string',
'comment' => '@string',
];
@@ -593,7 +593,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'visible' => 'bool',
'billable' => 'bool',
'project' => ['result' => 'object', 'type' => '@ProjectExpanded'],
'number' => '@int',
'number' => '@string',
'color' => '@string',
'comment' => '@string',
];
@@ -606,7 +606,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'visible' => 'bool',
'billable' => 'bool',
'project' => '@int',
'number' => '@int',
'number' => '@string',
'color' => '@string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'parentTitle' => '@string',
@@ -622,7 +622,7 @@ abstract class APIControllerBaseTestCase extends AbstractControllerBaseTestCase
'visible' => 'bool',
'billable' => 'bool',
'project' => '@int',
'number' => '@int',
'number' => '@string',
'color' => '@string',
'metaFields' => ['result' => 'array', 'type' => 'ProjectMeta'],
'parentTitle' => '@string',

View File

@@ -243,6 +243,24 @@ class ActivityControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertApiResponseTypeStructure('ActivityEntity', $result);
self::assertCount(14, array_keys($result));
self::assertNull($result['parentTitle']);
self::assertNotEmpty($result['id']);
self::assertIsArray($result['teams']);
self::assertEquals([], $result['teams']);
self::assertIsArray($result['metaFields']);
self::assertEquals([], $result['metaFields']);
self::assertEquals('Test', $result['name']);
self::assertNull($result['project']);
self::assertEquals(1000, $result['budget']);
self::assertEquals(100000, $result['timeBudget']);
self::assertNull($result['budgetType']);
self::assertNull($result['number']);
self::assertEquals('Test comment', $result['comment']);
self::assertNull($result['color']);
self::assertTrue($result['visible']);
self::assertTrue($result['billable']);
}
public function testNotFound(): void
@@ -256,9 +274,16 @@ class ActivityControllerTest extends APIControllerBaseTestCase
$data = [
'name' => 'foo',
'project' => 1,
'visible' => true,
'budget' => '999',
'timeBudget' => '7200',
'timeBudget' => '10,25',
'budgetType' => 'month',
'number' => 'P-754',
'comment' => 'Awesome activity since a short time',
'invoiceText' => 'Some invoice text, pay slow please',
'color' => '#000000',
'visible' => true,
'billable' => true,
'teams' => [1],
];
$this->request($client, '/api/activities', 'POST', [], json_encode($data));
self::assertTrue($client->getResponse()->isSuccessful());
@@ -269,7 +294,22 @@ class ActivityControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertApiResponseTypeStructure('ActivityEntity', $result);
self::assertEquals('Test', $result['parentTitle']);
self::assertNotEmpty($result['id']);
self::assertIsArray($result['teams']);
self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => null]], $result['teams']);
self::assertIsArray($result['metaFields']);
self::assertEquals([], $result['metaFields']);
self::assertEquals('foo', $result['name']);
self::assertEquals(1, $result['project']);
self::assertEquals('999', $result['budget']);
self::assertEquals('36900', $result['timeBudget']);
self::assertEquals('month', $result['budgetType']);
self::assertEquals('P-754', $result['number']);
self::assertEquals('Awesome activity since a short time', $result['comment']);
self::assertEquals('#000000', $result['color']);
self::assertTrue($result['visible']);
self::assertTrue($result['billable']);
}
public function testPostActionWithLeastFields(): void

View File

@@ -228,6 +228,43 @@ class CustomerControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertApiResponseTypeStructure('CustomerEntity', $result);
self::assertCount(30, array_keys($result));
self::assertNotEmpty($result['id']);
self::assertIsArray($result['teams']);
self::assertCount(1, $result['teams']);
self::assertIsArray($result['teams'][0]);
self::assertEquals('Testing customer 1 team', $result['teams'][0]['name']);
self::assertIsArray($result['metaFields']);
self::assertCount(1, $result['metaFields']);
self::assertEquals([['name' => 'foo', 'value' => 'bar']], $result['metaFields']);
self::assertEquals('Test', $result['name']);
self::assertEquals(1000, $result['budget']);
self::assertEquals(100000, $result['timeBudget']);
self::assertNull($result['budgetType']);
self::assertEquals('1', $result['number']);
self::assertEquals('Test comment', $result['comment']);
self::assertEquals('Test', $result['company']);
self::assertNull($result['vatId']);
self::assertEquals('Test', $result['contact']);
self::assertEquals('Test', $result['address']);
self::assertNull($result['addressLine1']);
self::assertNull($result['addressLine2']);
self::assertNull($result['addressLine3']);
self::assertNull($result['postCode']);
self::assertNull($result['city']);
self::assertEquals('DE', $result['country']);
self::assertEquals('EUR', $result['currency']);
self::assertEquals('111', $result['phone']);
self::assertEquals('222', $result['fax']);
self::assertEquals('333', $result['mobile']);
self::assertEquals('test@example.com', $result['email']);
self::assertNull($result['homepage']);
self::assertEquals('Europe/Berlin', $result['timezone']);
self::assertNull($result['buyerReference']);
self::assertNull($result['color']);
self::assertTrue($result['visible']);
self::assertTrue($result['billable']);
}
public function testNotFound(): void
@@ -240,12 +277,33 @@ class CustomerControllerTest extends APIControllerBaseTestCase
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$data = [
'name' => 'foo',
'visible' => true,
'budget' => '999',
'timeBudget' => '10,25',
'budgetType' => 'month',
'number' => 'C-754',
'comment' => 'Awesome customer since a long time',
'company' => 'IT Professional Comp.',
'vatId' => 'DE0123456789',
'contact' => 'Mr. John Doe',
'addressLine1' => 'test address line 1',
'addressLine2' => 'foo address line 2',
'addressLine3' => 'bar address line 3',
'postCode' => '12345',
'city' => 'Acme Town',
'country' => 'DE',
'currency' => 'EUR',
'phone' => '666667787778999909087',
'fax' => '0987654321',
'mobile' => '01234534567890',
'email' => 'admin@example.com',
'homepage' => 'https://www.example.com/',
'timezone' => 'Europe/Berlin',
'budget' => '999',
'timeBudget' => '7200',
'invoiceText' => 'Some random text, pay fast please!',
'buyerReference' => 'REF-0123456789',
'color' => '#ff0000',
'visible' => true,
'billable' => true,
'teams' => [1],
];
$this->request($client, '/api/customers', 'POST', [], json_encode($data));
self::assertTrue($client->getResponse()->isSuccessful());
@@ -257,6 +315,37 @@ class CustomerControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertApiResponseTypeStructure('CustomerEntity', $result);
self::assertNotEmpty($result['id']);
self::assertIsArray($result['teams']);
self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => null]], $result['teams']);
self::assertIsArray($result['metaFields']);
self::assertEquals([], $result['metaFields']);
self::assertEquals('foo', $result['name']);
self::assertEquals('999', $result['budget']);
self::assertEquals('36900', $result['timeBudget']);
self::assertEquals('month', $result['budgetType']);
self::assertEquals('C-754', $result['number']);
self::assertEquals('Awesome customer since a long time', $result['comment']);
self::assertEquals('IT Professional Comp.', $result['company']);
self::assertEquals('DE0123456789', $result['vatId']);
self::assertEquals('Mr. John Doe', $result['contact']);
self::assertNull($result['address']);
self::assertEquals('test address line 1', $result['addressLine1']);
self::assertEquals('foo address line 2', $result['addressLine2']);
self::assertEquals('bar address line 3', $result['addressLine3']);
self::assertEquals('12345', $result['postCode']);
self::assertEquals('Acme Town', $result['city']);
self::assertEquals('DE', $result['country']);
self::assertEquals('EUR', $result['currency']);
self::assertEquals('666667787778999909087', $result['phone']);
self::assertEquals('0987654321', $result['fax']);
self::assertEquals('01234534567890', $result['mobile']);
self::assertEquals('admin@example.com', $result['email']);
self::assertEquals('https://www.example.com/', $result['homepage']);
self::assertEquals('Europe/Berlin', $result['timezone']);
self::assertEquals('REF-0123456789', $result['buyerReference']);
self::assertEquals('#ff0000', $result['color']);
self::assertTrue($result['visible']);
self::assertTrue($result['billable']);
}
public function testPostActionWithLeastFields(): void

View File

@@ -311,28 +311,28 @@ class ProjectControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertApiResponseTypeStructure('ProjectEntity', $result);
$expected = [
'parentTitle' => 'first one',
'customer' => $customer->getId(),
'id' => $project->getId(),
'name' => 'first',
'orderNumber' => null,
// make sure the timezone is properly applied in serializer (see #1858)
'orderDate' => '2019-11-29',
'start' => '2020-01-07',
'end' => '2021-03-23',
'comment' => null,
'visible' => true,
'budget' => 0.0,
'timeBudget' => 0,
'metaFields' => [],
'teams' => [],
'color' => null,
];
foreach ($expected as $key => $value) {
self::assertEquals($value, $result[$key]);
}
self::assertCount(19, array_keys($result));
self::assertEquals('first one', $result['parentTitle']);
self::assertEquals($project->getId(), $result['id']);
self::assertIsArray($result['teams']);
self::assertEquals([], $result['teams']);
self::assertIsArray($result['metaFields']);
self::assertEquals([], $result['metaFields']);
self::assertEquals('first', $result['name']);
self::assertEquals($customer->getId(), $result['customer']);
self::assertEquals('2019-11-29', $result['orderDate']);
self::assertEquals('2020-01-07', $result['start']);
self::assertEquals('2021-03-23', $result['end']);
self::assertEquals(0.0, $result['budget']);
self::assertEquals(0, $result['timeBudget']);
self::assertNull($result['budgetType']);
self::assertNull($result['orderNumber']);
self::assertNull($result['number']);
self::assertNull($result['comment']);
self::assertNull($result['color']);
self::assertTrue($result['globalActivities']);
self::assertTrue($result['billable']);
self::assertTrue($result['visible']);
}
public function testNotFound(): void
@@ -350,8 +350,17 @@ class ProjectControllerTest extends APIControllerBaseTestCase
'start' => '2019-02-01',
'end' => '2020-02-08',
'budget' => '999',
'timeBudget' => '7200',
'timeBudget' => '10,25',
'budgetType' => 'month',
'orderNumber' => '1234567890/WXYZ/SUBPROJECT/1234/CONTRACT/EMPLOYEE1',
'number' => 'A-1234',
'comment' => 'Awesome project since a short time',
'invoiceText' => 'Some invoice text, pay now!',
'color' => '#c0c0c0',
'globalActivities' => true,
'visible' => true,
'billable' => true,
'teams' => [1],
];
$this->request($client, '/api/projects', 'POST', [], json_encode($data));
self::assertTrue($client->getResponse()->isSuccessful());
@@ -362,14 +371,27 @@ class ProjectControllerTest extends APIControllerBaseTestCase
self::assertIsArray($result);
self::assertApiResponseTypeStructure('ProjectEntity', $result);
self::assertEquals('Test', $result['parentTitle']);
self::assertNotEmpty($result['id']);
self::assertIsArray($result['teams']);
self::assertEquals([['id' => 1, 'name' => 'Test team', 'color' => null]], $result['teams']);
self::assertIsArray($result['metaFields']);
self::assertEquals([], $result['metaFields']);
self::assertEquals('foo', $result['name']);
self::assertEquals(1, $result['customer']);
self::assertEquals('2018-04-17', $result['orderDate']);
self::assertEquals('2019-02-01', $result['start']);
self::assertEquals('2020-02-08', $result['end']);
self::assertEquals('999', $result['budget']);
self::assertEquals('36900', $result['timeBudget']);
self::assertEquals('month', $result['budgetType']);
self::assertEquals('1234567890/WXYZ/SUBPROJECT/1234/CONTRACT/EMPLOYEE1', $result['orderNumber']);
self::assertFalse($result['globalActivities']);
self::assertFalse($result['billable']);
self::assertFalse($result['visible']);
self::assertEquals('A-1234', $result['number']);
self::assertEquals('Awesome project since a short time', $result['comment']);
self::assertEquals('#c0c0c0', $result['color']);
self::assertTrue($result['globalActivities']);
self::assertTrue($result['billable']);
self::assertTrue($result['visible']);
}
public function testPostActionWithOtherFields(): void

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Activity;
use App\Event\ActivityDeleteEvent;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(ActivityDeleteEvent::class)]
class ActivityDeleteEventTest extends AbstractActivityEventTestCase
{
protected function createActivityEvent(Activity $activity): ActivityDeleteEvent
{
return new ActivityDeleteEvent($activity);
}
public function testReplacement(): void
{
$activity = new Activity();
$activity->setName('activity 1');
$replacement = new Activity();
$replacement->setName('activity 2');
$sut = new ActivityDeleteEvent($activity, $replacement);
self::assertSame($activity, $sut->getActivity());
self::assertSame($replacement, $sut->getReplacementActivity());
}
}

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Customer;
use App\Event\AbstractCustomerEvent;
use App\Event\CustomerDeleteEvent;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(CustomerDeleteEvent::class)]
class CustomerDeleteEventTest extends AbstractCustomerEventTestCase
{
protected function createCustomerEvent(Customer $customer): AbstractCustomerEvent
{
return new CustomerDeleteEvent($customer);
}
public function testReplacement(): void
{
$entity = new Customer('customer 1');
$replacement = new Customer('customer 2');
$sut = new CustomerDeleteEvent($entity, $replacement);
self::assertSame($entity, $sut->getCustomer());
self::assertSame($replacement, $sut->getReplacementCustomer());
}
}

View File

@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Project;
use App\Event\AbstractProjectEvent;
use App\Event\ProjectDeleteEvent;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(ProjectDeleteEvent::class)]
class ProjectDeleteEventTest extends AbstractProjectEventTestCase
{
protected function createProjectEvent(Project $project): AbstractProjectEvent
{
return new ProjectDeleteEvent($project);
}
public function testReplacement(): void
{
$entity = new Project();
$entity->setName('project 1');
$replacement = new Project();
$replacement->setName('project 2');
$sut = new ProjectDeleteEvent($entity, $replacement);
self::assertSame($entity, $sut->getProject());
self::assertSame($replacement, $sut->getReplacementProject());
}
}

View File

@@ -81,4 +81,27 @@ class UtilTest extends TestCase
self::assertEqualsWithDelta(1147.51 * $repeat, $total, 0.00001);
self::assertEqualsWithDelta(149176.3, $total, 0.00001);
}
public function testDecimalDuration(): void
{
$inputs = [
[900, 900],
[1600, 1584],
[4200, 4212],
[8763, 8748],
[3300, 3312],
[600, 612],
[1300, 1296],
[1837, 1836],
[4217, 4212],
[5400, 5400],
[3283, 3276],
[600, 612],
[7200, 7200],
];
foreach ($inputs as $row) {
self::assertEquals($row[1], Util::decimalizeDuration($row[0]));
}
}
}

View File

@@ -911,16 +911,6 @@ parameters:
count: 2
path: Controller/TeamControllerTest.php
-
message: "#^Cannot call method modify\\(\\) on DateTime\\|null\\.$#"
count: 1
path: Controller/TimesheetControllerTest.php
-
message: "#^Cannot call method setTime\\(\\) on DateTime\\|null\\.$#"
count: 1
path: Controller/TimesheetControllerTest.php
-
message: "#^Cannot clone DateTime\\|null\\.$#"
count: 2
@@ -956,11 +946,6 @@ parameters:
count: 1
path: Controller/TimesheetControllerTest.php
-
message: "#^Parameter \\#1 \\$begin of method App\\\\Entity\\\\Timesheet\\:\\:setBegin\\(\\) expects DateTime, DateTime\\|null given\\.$#"
count: 1
path: Controller/TimesheetControllerTest.php
-
message: "#^Parameter \\#2 \\$haystack of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|false given\\.$#"
count: 1
@@ -971,26 +956,11 @@ parameters:
count: 3
path: Controller/TimesheetTeamControllerTest.php
-
message: "#^Cannot call method modify\\(\\) on DateTime\\|null\\.$#"
count: 1
path: Controller/TimesheetTeamControllerTest.php
-
message: "#^Cannot call method setTime\\(\\) on DateTime\\|null\\.$#"
count: 1
path: Controller/TimesheetTeamControllerTest.php
-
message: "#^Cannot clone DateTime\\|null\\.$#"
count: 2
path: Controller/TimesheetTeamControllerTest.php
-
message: "#^Parameter \\#1 \\$begin of method App\\\\Entity\\\\Timesheet\\:\\:setBegin\\(\\) expects DateTime, DateTime\\|null given\\.$#"
count: 1
path: Controller/TimesheetTeamControllerTest.php
-
message: "#^Parameter \\#2 \\$haystack of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|false given\\.$#"
count: 1

View File

@@ -18,9 +18,9 @@
<source>user</source>
<target state="translated">Användare</target>
</trans-unit>
<trans-unit id="BlGGO.X" resname="activity">
<trans-unit id="BlGGO.X" resname="activity" xml:space="preserve">
<source>activity</source>
<target state="translated">Aktivitet grupperad - en post per aktivitet</target>
<target state="translated">Aktivitet</target>
</trans-unit>
<trans-unit id="JEIQ5IQ" resname="project" xml:space="preserve">
<source>project</source>

View File

@@ -1986,6 +1986,10 @@
<source>remaining_budget</source>
<target state="translated">Saldo</target>
</trans-unit>
<trans-unit id="jcT9sdfOg6" resname="electronic_invoice" xml:space="preserve">
<source>electronic_invoice</source>
<target state="translated">Factura electrónica</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2042,6 +2042,10 @@
<source>buyerReference</source>
<target state="final">זיהוי רוכש</target>
</trans-unit>
<trans-unit id="jcT9sdfOg6" resname="electronic_invoice" xml:space="preserve" approved="yes">
<source>electronic_invoice</source>
<target state="final">חשבונית דיגיטלית</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2042,6 +2042,10 @@
<source>payment_account_name</source>
<target state="translated">IBAN ili broj kartice</target>
</trans-unit>
<trans-unit id="jcT9sdfOg6" resname="electronic_invoice" xml:space="preserve" approved="no">
<source>electronic_invoice</source>
<target state="translated">E-račun</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -174,7 +174,7 @@
<source>timeBudget</source>
<target state="translated">Kvot per timme</target>
</trans-unit>
<trans-unit id="BlGGO.X" resname="activity">
<trans-unit id="BlGGO.X" resname="activity" xml:space="preserve">
<source>activity</source>
<target state="translated">Aktivitet</target>
</trans-unit>
@@ -2042,6 +2042,10 @@
<source>payment_account_name</source>
<target state="translated">IBAN eller kortnummer</target>
</trans-unit>
<trans-unit id="jcT9sdfOg6" resname="electronic_invoice" xml:space="preserve">
<source>electronic_invoice</source>
<target state="translated">E-faktura</target>
</trans-unit>
</body>
</file>
</xliff>