Release 2.15 (#4749)

This commit is contained in:
Kevin Papst
2024-04-12 19:03:09 +02:00
committed by GitHub
parent b76a5d5c35
commit 7219b3f421
56 changed files with 441 additions and 575 deletions

View File

@@ -84,6 +84,7 @@
"fakerphp/faker": "^1.15", "fakerphp/faker": "^1.15",
"friendsofphp/php-cs-fixer": "^3.3", "friendsofphp/php-cs-fixer": "^3.3",
"phpstan/phpstan": "^1.0", "phpstan/phpstan": "^1.0",
"phpstan/phpstan-deprecation-rules": "^1.0",
"phpstan/phpstan-doctrine": "^1.0", "phpstan/phpstan-doctrine": "^1.0",
"phpstan/phpstan-phpunit": "^1.0", "phpstan/phpstan-phpunit": "^1.0",
"phpstan/phpstan-strict-rules": "^1.0", "phpstan/phpstan-strict-rules": "^1.0",

147
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "24f571304052968a6a7f49cac1109c5b", "content-hash": "e857da36ba58dbba786a711ffe4f6320",
"packages": [ "packages": [
{ {
"name": "azuyalabs/yasumi", "name": "azuyalabs/yasumi",
@@ -2374,16 +2374,16 @@
}, },
{ {
"name": "kevinpapst/tabler-bundle", "name": "kevinpapst/tabler-bundle",
"version": "1.3.0", "version": "1.4.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/kevinpapst/TablerBundle.git", "url": "https://github.com/kevinpapst/TablerBundle.git",
"reference": "b4519deb2cb9fff3493c108c73a13af54401b5e8" "reference": "d957405e18589a351856e4b7b8670731a5c34896"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/kevinpapst/TablerBundle/zipball/b4519deb2cb9fff3493c108c73a13af54401b5e8", "url": "https://api.github.com/repos/kevinpapst/TablerBundle/zipball/d957405e18589a351856e4b7b8670731a5c34896",
"reference": "b4519deb2cb9fff3493c108c73a13af54401b5e8", "reference": "d957405e18589a351856e4b7b8670731a5c34896",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2431,7 +2431,7 @@
"description": "Admin/Backend theme bundle for Symfony based on Tabler.io", "description": "Admin/Backend theme bundle for Symfony based on Tabler.io",
"support": { "support": {
"issues": "https://github.com/kevinpapst/TablerBundle/issues", "issues": "https://github.com/kevinpapst/TablerBundle/issues",
"source": "https://github.com/kevinpapst/TablerBundle/tree/1.3.0" "source": "https://github.com/kevinpapst/TablerBundle/tree/1.4.0"
}, },
"funding": [ "funding": [
{ {
@@ -2443,7 +2443,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2024-02-12T16:13:20+00:00" "time": "2024-04-12T10:09:29+00:00"
}, },
{ {
"name": "league/csv", "name": "league/csv",
@@ -3110,16 +3110,16 @@
}, },
{ {
"name": "nelmio/api-doc-bundle", "name": "nelmio/api-doc-bundle",
"version": "v4.25.1", "version": "4.25.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/nelmio/NelmioApiDocBundle.git", "url": "https://github.com/nelmio/NelmioApiDocBundle.git",
"reference": "f594e600bf5943008562be35c4acd60957434d22" "reference": "fc5d60d74b63806f7d6bb80b8393db1a12ae2dbc"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/f594e600bf5943008562be35c4acd60957434d22", "url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/fc5d60d74b63806f7d6bb80b8393db1a12ae2dbc",
"reference": "f594e600bf5943008562be35c4acd60957434d22", "reference": "fc5d60d74b63806f7d6bb80b8393db1a12ae2dbc",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3153,8 +3153,11 @@
"friendsofsymfony/rest-bundle": "^2.8|^3.0", "friendsofsymfony/rest-bundle": "^2.8|^3.0",
"jms/serializer": "^1.14|^3.0", "jms/serializer": "^1.14|^3.0",
"jms/serializer-bundle": "^2.3|^3.0|^4.0|^5.0", "jms/serializer-bundle": "^2.3|^3.0|^4.0|^5.0",
"phpstan/phpstan": "^1.10",
"phpstan/phpstan-phpunit": "^1.3",
"phpstan/phpstan-strict-rules": "^1.5",
"phpstan/phpstan-symfony": "^1.3",
"phpunit/phpunit": "^8.5|^9.6", "phpunit/phpunit": "^8.5|^9.6",
"sensio/framework-extra-bundle": "^5.4|^6.0",
"symfony/asset": "^5.4|^6.0|^7.0", "symfony/asset": "^5.4|^6.0|^7.0",
"symfony/browser-kit": "^5.4|^6.0|^7.0", "symfony/browser-kit": "^5.4|^6.0|^7.0",
"symfony/cache": "^5.4|^6.0|^7.0", "symfony/cache": "^5.4|^6.0|^7.0",
@@ -3216,9 +3219,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/nelmio/NelmioApiDocBundle/issues", "issues": "https://github.com/nelmio/NelmioApiDocBundle/issues",
"source": "https://github.com/nelmio/NelmioApiDocBundle/tree/v4.25.1" "source": "https://github.com/nelmio/NelmioApiDocBundle/tree/4.25.2"
}, },
"time": "2024-04-04T08:16:08+00:00" "time": "2024-04-06T12:08:11+00:00"
}, },
{ {
"name": "nelmio/cors-bundle", "name": "nelmio/cors-bundle",
@@ -3613,28 +3616,35 @@
}, },
{ {
"name": "phpdocumentor/reflection-docblock", "name": "phpdocumentor/reflection-docblock",
"version": "5.3.0", "version": "5.4.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
"reference": "622548b623e81ca6d78b721c5e029f4ce664f170" "reference": "298d2febfe79d03fe714eb871d5538da55205b1a"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/622548b623e81ca6d78b721c5e029f4ce664f170", "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/298d2febfe79d03fe714eb871d5538da55205b1a",
"reference": "622548b623e81ca6d78b721c5e029f4ce664f170", "reference": "298d2febfe79d03fe714eb871d5538da55205b1a",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"doctrine/deprecations": "^1.1",
"ext-filter": "*", "ext-filter": "*",
"php": "^7.2 || ^8.0", "php": "^7.4 || ^8.0",
"phpdocumentor/reflection-common": "^2.2", "phpdocumentor/reflection-common": "^2.2",
"phpdocumentor/type-resolver": "^1.3", "phpdocumentor/type-resolver": "^1.7",
"phpstan/phpdoc-parser": "^1.7",
"webmozart/assert": "^1.9.1" "webmozart/assert": "^1.9.1"
}, },
"require-dev": { "require-dev": {
"mockery/mockery": "~1.3.2", "mockery/mockery": "~1.3.5",
"psalm/phar": "^4.8" "phpstan/extension-installer": "^1.1",
"phpstan/phpstan": "^1.8",
"phpstan/phpstan-mockery": "^1.1",
"phpstan/phpstan-webmozart-assert": "^1.2",
"phpunit/phpunit": "^9.5",
"vimeo/psalm": "^5.13"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@@ -3658,15 +3668,15 @@
}, },
{ {
"name": "Jaap van Otterdijk", "name": "Jaap van Otterdijk",
"email": "account@ijaap.nl" "email": "opensource@ijaap.nl"
} }
], ],
"description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
"support": { "support": {
"issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
"source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.3.0" "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.4.0"
}, },
"time": "2021-10-19T17:43:47+00:00" "time": "2024-04-09T21:13:58+00:00"
}, },
{ {
"name": "phpdocumentor/type-resolver", "name": "phpdocumentor/type-resolver",
@@ -10769,16 +10779,16 @@
}, },
{ {
"name": "friendsofphp/php-cs-fixer", "name": "friendsofphp/php-cs-fixer",
"version": "v3.52.1", "version": "v3.53.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
"reference": "6e77207f0d851862ceeb6da63e6e22c01b1587bc" "reference": "69a19093a9ded8d1baac62ed6c009b8bc148d008"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/6e77207f0d851862ceeb6da63e6e22c01b1587bc", "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/69a19093a9ded8d1baac62ed6c009b8bc148d008",
"reference": "6e77207f0d851862ceeb6da63e6e22c01b1587bc", "reference": "69a19093a9ded8d1baac62ed6c009b8bc148d008",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -10802,6 +10812,7 @@
}, },
"require-dev": { "require-dev": {
"facile-it/paraunit": "^1.3 || ^2.0", "facile-it/paraunit": "^1.3 || ^2.0",
"infection/infection": "^0.27.11",
"justinrainbow/json-schema": "^5.2", "justinrainbow/json-schema": "^5.2",
"keradus/cli-executor": "^2.1", "keradus/cli-executor": "^2.1",
"mikey179/vfsstream": "^1.6.11", "mikey179/vfsstream": "^1.6.11",
@@ -10849,7 +10860,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.52.1" "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.53.0"
}, },
"funding": [ "funding": [
{ {
@@ -10857,7 +10868,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2024-03-19T21:02:43+00:00" "time": "2024-04-08T15:03:00+00:00"
}, },
{ {
"name": "masterminds/html5", "name": "masterminds/html5",
@@ -11164,6 +11175,54 @@
], ],
"time": "2024-03-28T16:17:31+00:00" "time": "2024-03-28T16:17:31+00:00"
}, },
{
"name": "phpstan/phpstan-deprecation-rules",
"version": "1.1.4",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-deprecation-rules.git",
"reference": "089d8a8258ed0aeefdc7b68b6c3d25572ebfdbaa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/089d8a8258ed0aeefdc7b68b6c3d25572ebfdbaa",
"reference": "089d8a8258ed0aeefdc7b68b6c3d25572ebfdbaa",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0",
"phpstan/phpstan": "^1.10.3"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/phpstan-php-parser": "^1.1",
"phpstan/phpstan-phpunit": "^1.0",
"phpunit/phpunit": "^9.5"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"rules.neon"
]
}
},
"autoload": {
"psr-4": {
"PHPStan\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan rules for detecting usage of deprecated classes, methods, properties, constants and traits.",
"support": {
"issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues",
"source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/1.1.4"
},
"time": "2023-08-05T09:02:04+00:00"
},
{ {
"name": "phpstan/phpstan-doctrine", "name": "phpstan/phpstan-doctrine",
"version": "1.3.65", "version": "1.3.65",
@@ -11290,21 +11349,21 @@
}, },
{ {
"name": "phpstan/phpstan-strict-rules", "name": "phpstan/phpstan-strict-rules",
"version": "1.5.2", "version": "1.5.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpstan-strict-rules.git", "url": "https://github.com/phpstan/phpstan-strict-rules.git",
"reference": "7a50e9662ee9f3942e4aaaf3d603653f60282542" "reference": "568210bd301f94a0d4b1e5a0808c374c1b9cf11b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/7a50e9662ee9f3942e4aaaf3d603653f60282542", "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/568210bd301f94a0d4b1e5a0808c374c1b9cf11b",
"reference": "7a50e9662ee9f3942e4aaaf3d603653f60282542", "reference": "568210bd301f94a0d4b1e5a0808c374c1b9cf11b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.2 || ^8.0", "php": "^7.2 || ^8.0",
"phpstan/phpstan": "^1.10.34" "phpstan/phpstan": "^1.10.60"
}, },
"require-dev": { "require-dev": {
"nikic/php-parser": "^4.13.0", "nikic/php-parser": "^4.13.0",
@@ -11333,22 +11392,22 @@
"description": "Extra strict and opinionated rules for PHPStan", "description": "Extra strict and opinionated rules for PHPStan",
"support": { "support": {
"issues": "https://github.com/phpstan/phpstan-strict-rules/issues", "issues": "https://github.com/phpstan/phpstan-strict-rules/issues",
"source": "https://github.com/phpstan/phpstan-strict-rules/tree/1.5.2" "source": "https://github.com/phpstan/phpstan-strict-rules/tree/1.5.3"
}, },
"time": "2023-10-30T14:35:06+00:00" "time": "2024-04-06T07:43:25+00:00"
}, },
{ {
"name": "phpstan/phpstan-symfony", "name": "phpstan/phpstan-symfony",
"version": "1.3.9", "version": "1.3.11",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpstan-symfony.git", "url": "https://github.com/phpstan/phpstan-symfony.git",
"reference": "a32bc86da24495025d7aafd1ba62444d4a364a98" "reference": "3fbf634b2495adaa1dc4b7300293f8abf74128d8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/a32bc86da24495025d7aafd1ba62444d4a364a98", "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/3fbf634b2495adaa1dc4b7300293f8abf74128d8",
"reference": "a32bc86da24495025d7aafd1ba62444d4a364a98", "reference": "3fbf634b2495adaa1dc4b7300293f8abf74128d8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11405,9 +11464,9 @@
"description": "Symfony Framework extensions and rules for PHPStan", "description": "Symfony Framework extensions and rules for PHPStan",
"support": { "support": {
"issues": "https://github.com/phpstan/phpstan-symfony/issues", "issues": "https://github.com/phpstan/phpstan-symfony/issues",
"source": "https://github.com/phpstan/phpstan-symfony/tree/1.3.9" "source": "https://github.com/phpstan/phpstan-symfony/tree/1.3.11"
}, },
"time": "2024-03-16T16:50:20+00:00" "time": "2024-04-12T07:42:40+00:00"
}, },
{ {
"name": "phpunit/php-code-coverage", "name": "phpunit/php-code-coverage",

View File

@@ -23,6 +23,8 @@ doctrine:
types: types:
datetime: App\Doctrine\UTCDateTimeType datetime: App\Doctrine\UTCDateTimeType
orm: orm:
controller_resolver:
auto_mapping: true
auto_generate_proxy_classes: '%kernel.debug%' auto_generate_proxy_classes: '%kernel.debug%'
default_entity_manager: default default_entity_manager: default
enable_lazy_ghost_objects: true enable_lazy_ghost_objects: true

View File

@@ -20,6 +20,7 @@ security:
api: api:
access_token: access_token:
token_handler: App\API\Authentication\AccessTokenHandler token_handler: App\API\Authentication\AccessTokenHandler
remember_me: false
request_matcher: App\API\Authentication\ApiRequestMatcher request_matcher: App\API\Authentication\ApiRequestMatcher
user_checker: App\Security\UserChecker user_checker: App\Security\UserChecker
stateless: true stateless: true
@@ -92,7 +93,7 @@ security:
- { path: '^/(%app_locales%)/register', role: PUBLIC_ACCESS } - { path: '^/(%app_locales%)/register', role: PUBLIC_ACCESS }
- { path: '^/(%app_locales%)/resetting', role: PUBLIC_ACCESS } - { path: '^/(%app_locales%)/resetting', role: PUBLIC_ACCESS }
- { path: '^/(%app_locales%)/', roles: ROLE_USER } - { path: '^/(%app_locales%)/', roles: ROLE_USER }
- { path: '^/api', roles: IS_AUTHENTICATED_REMEMBERED } - { path: '^/api', roles: IS_AUTHENTICATED }
when@test: when@test:
# this configuration simplifies testing URLs protected by the security mechanism # this configuration simplifies testing URLs protected by the security mechanism

View File

@@ -4,6 +4,7 @@ includes:
- %rootDir%/../phpstan-doctrine/extension.neon - %rootDir%/../phpstan-doctrine/extension.neon
- %rootDir%/../phpstan-doctrine/rules.neon - %rootDir%/../phpstan-doctrine/rules.neon
- %rootDir%/../phpstan-strict-rules/rules.neon - %rootDir%/../phpstan-strict-rules/rules.neon
- %rootDir%/../phpstan-deprecation-rules/rules.neon
- %rootDir%/../phpstan/conf/bleedingEdge.neon - %rootDir%/../phpstan/conf/bleedingEdge.neon
parameters: parameters:
@@ -3447,11 +3448,6 @@ parameters:
count: 1 count: 1
path: src/Invoice/Calculator/AbstractMergedCalculator.php path: src/Invoice/Calculator/AbstractMergedCalculator.php
-
message: "#^Cannot call method format\\(\\) on DateTime\\|null\\.$#"
count: 2
path: src/Invoice/Hydrator/InvoiceItemDefaultHydrator.php
- -
message: "#^Cannot call method getCurrency\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#" message: "#^Cannot call method getCurrency\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1 count: 1
@@ -3462,26 +3458,11 @@ parameters:
count: 1 count: 1
path: src/Invoice/Hydrator/InvoiceItemDefaultHydrator.php path: src/Invoice/Hydrator/InvoiceItemDefaultHydrator.php
-
message: "#^Cannot call method getTimestamp\\(\\) on DateTime\\|null\\.$#"
count: 2
path: src/Invoice/Hydrator/InvoiceItemDefaultHydrator.php
- -
message: "#^Method App\\\\Invoice\\\\Hydrator\\\\InvoiceItemDefaultHydrator\\:\\:hydrate\\(\\) return type has no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Invoice\\\\Hydrator\\\\InvoiceItemDefaultHydrator\\:\\:hydrate\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1 count: 1
path: src/Invoice/Hydrator/InvoiceItemDefaultHydrator.php path: src/Invoice/Hydrator/InvoiceItemDefaultHydrator.php
-
message: "#^Parameter \\#1 \\$date of method App\\\\Invoice\\\\InvoiceFormatter\\:\\:getFormattedDateTime\\(\\) expects DateTimeInterface, DateTime\\|null given\\.$#"
count: 3
path: src/Invoice/Hydrator/InvoiceItemDefaultHydrator.php
-
message: "#^Parameter \\#1 \\$date of method App\\\\Invoice\\\\InvoiceFormatter\\:\\:getFormattedTime\\(\\) expects DateTimeInterface, DateTime\\|null given\\.$#"
count: 2
path: src/Invoice/Hydrator/InvoiceItemDefaultHydrator.php
- -
message: "#^Method App\\\\Invoice\\\\Hydrator\\\\InvoiceModelActivityHydrator\\:\\:getBudgetValues\\(\\) return type has no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Invoice\\\\Hydrator\\\\InvoiceModelActivityHydrator\\:\\:getBudgetValues\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1 count: 1
@@ -5572,95 +5553,15 @@ parameters:
count: 1 count: 1
path: src/Voter/TimesheetVoter.php path: src/Voter/TimesheetVoter.php
-
message: "#^Cannot access offset 'billable' on mixed\\.$#"
count: 1
path: src/Widget/DataProvider/DailyWorkingTimeChartProvider.php
-
message: "#^Cannot access offset 'day' on mixed\\.$#"
count: 1
path: src/Widget/DataProvider/DailyWorkingTimeChartProvider.php
-
message: "#^Cannot access offset 'details' on mixed\\.$#"
count: 1
path: src/Widget/DataProvider/DailyWorkingTimeChartProvider.php
-
message: "#^Cannot access offset 'duration' on mixed\\.$#"
count: 1
path: src/Widget/DataProvider/DailyWorkingTimeChartProvider.php
-
message: "#^Cannot access offset 'month' on mixed\\.$#"
count: 1
path: src/Widget/DataProvider/DailyWorkingTimeChartProvider.php
-
message: "#^Cannot access offset 'rate' on mixed\\.$#"
count: 1
path: src/Widget/DataProvider/DailyWorkingTimeChartProvider.php
-
message: "#^Cannot access offset 'year' on mixed\\.$#"
count: 1
path: src/Widget/DataProvider/DailyWorkingTimeChartProvider.php
- -
message: "#^Cannot call method getCustomer\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#" message: "#^Cannot call method getCustomer\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
count: 1 count: 1
path: src/Widget/DataProvider/DailyWorkingTimeChartProvider.php path: src/Widget/Type/DailyWorkingTimeChart.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Activity\\|null\\.$#"
count: 1
path: src/Widget/DataProvider/DailyWorkingTimeChartProvider.php
- -
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#" message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1 count: 1
path: src/Widget/DataProvider/DailyWorkingTimeChartProvider.php path: src/Widget/Type/DailyWorkingTimeChart.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
count: 1
path: src/Widget/DataProvider/DailyWorkingTimeChartProvider.php
-
message: "#^Cannot cast mixed to float\\.$#"
count: 1
path: src/Widget/DataProvider/DailyWorkingTimeChartProvider.php
-
message: "#^Cannot cast mixed to int\\.$#"
count: 1
path: src/Widget/DataProvider/DailyWorkingTimeChartProvider.php
-
message: "#^Parameter \\#1 \\$details of method App\\\\Model\\\\Statistic\\\\Day\\:\\:setDetails\\(\\) expects array, mixed given\\.$#"
count: 1
path: src/Widget/DataProvider/DailyWorkingTimeChartProvider.php
-
message: "#^Parameter \\#1 \\$seconds of method App\\\\Model\\\\Statistic\\\\Day\\:\\:setTotalDurationBillable\\(\\) expects int, mixed given\\.$#"
count: 1
path: src/Widget/DataProvider/DailyWorkingTimeChartProvider.php
-
message: "#^Parameter \\#1 \\$year of method DateTime\\:\\:setDate\\(\\) expects int, mixed given\\.$#"
count: 1
path: src/Widget/DataProvider/DailyWorkingTimeChartProvider.php
-
message: "#^Parameter \\#2 \\$month of method DateTime\\:\\:setDate\\(\\) expects int, mixed given\\.$#"
count: 1
path: src/Widget/DataProvider/DailyWorkingTimeChartProvider.php
-
message: "#^Parameter \\#3 \\$day of method DateTime\\:\\:setDate\\(\\) expects int, mixed given\\.$#"
count: 1
path: src/Widget/DataProvider/DailyWorkingTimeChartProvider.php
- -
message: "#^Method App\\\\Widget\\\\Type\\\\AbstractWidget\\:\\:getUser\\(\\) should return App\\\\Entity\\\\User but returns App\\\\Entity\\\\User\\|null\\.$#" message: "#^Method App\\\\Widget\\\\Type\\\\AbstractWidget\\:\\:getUser\\(\\) should return App\\\\Entity\\\\User but returns App\\\\Entity\\\\User\\|null\\.$#"

View File

@@ -1,3 +1,5 @@
# See https://github.com/symfony/recipes-contrib/blob/main/symfony/apache-pack/1.0/public/.htaccess#L38
# Use the front controller as index file. It serves as a fallback solution when # Use the front controller as index file. It serves as a fallback solution when
# every other rewrite/redirect fails (e.g. in an aliased environment without # every other rewrite/redirect fails (e.g. in an aliased environment without
# mod_rewrite). Additionally, this reduces the matching process for the # mod_rewrite). Additionally, this reduces the matching process for the
@@ -5,30 +7,41 @@
# to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl). # to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl).
DirectoryIndex index.php DirectoryIndex index.php
# Do NOT use Kimai with http, this is a huge security risk! # By default, Apache does not evaluate symbolic links if you did not enable this
# Activate these lines if you configured SSL and certificates: # feature in your server configuration. Uncomment the following line if you
# install assets as symlinks or if you experience problems related to symlinks
# when compiling LESS/Sass/CoffeScript assets.
# Options +SymLinksIfOwnerMatch
#<IfModule mod_rewrite.c> # Disabling MultiViews prevents unwanted negotiation, e.g. "/index" should not resolve
# RewriteEngine On # to the front controller "/index.php" but be rewritten to "/index.php/index".
# RewriteCond %{SERVER_PORT} 80 <IfModule mod_negotiation.c>
# RewriteRule ^(.*)$ https://kimai.example.com/$1 [R,L] Options -MultiViews
#</IfModule> </IfModule>
<IfModule mod_rewrite.c> <IfModule mod_rewrite.c>
# This Option needs to be enabled for RewriteRule, otherwise it will show an error like
# 'Options FollowSymLinks or SymLinksIfOwnerMatch is off which implies that RewriteRule directive is forbidden'
Options +SymLinksIfOwnerMatch
RewriteEngine On RewriteEngine On
# Determine the RewriteBase automatically and set it as environment variable. # Determine the RewriteBase automatically and set it as environment variable.
# If you are using Apache aliases to do mass virtual hosting or installed the # If you are using Apache aliases to do mass virtual hosting or installed the
# project in a subdirectory, the base path will be prepended to allow proper # project in a subdirectory, the base path will be prepended to allow proper
# resolution of the app.php file and to redirect to the correct URI. It will # resolution of the index.php file and to redirect to the correct URI. It will
# work in environments without path prefix as well, providing a safe, one-size # work in environments without path prefix as well, providing a safe, one-size
# fits all solution. But as you do not need it in this case, you can comment # fits all solution. But as you do not need it in this case, you can comment
# the following 2 lines to eliminate the overhead. # the following 2 lines to eliminate the overhead.
RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$ RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\2$
RewriteRule ^(.*) - [E=BASE:%1] RewriteRule .* - [E=BASE:%1]
# Sets the HTTP_AUTHORIZATION header removed by Apache
RewriteCond %{HTTP:Authorization} .+
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
# Redirect to URI without front controller to prevent duplicate content # Redirect to URI without front controller to prevent duplicate content
# (with and without `/app.php`). Only do this redirect on the initial # (with and without `/index.php`). Only do this redirect on the initial
# rewrite by Apache and not on subsequent cycles. Otherwise we would get an # rewrite by Apache and not on subsequent cycles. Otherwise we would get an
# endless redirect loop (request -> rewrite to front controller -> # endless redirect loop (request -> rewrite to front controller ->
# redirect -> request -> ...). # redirect -> request -> ...).
@@ -38,16 +51,14 @@ DirectoryIndex index.php
# - disable this feature by commenting the following 2 lines or # - disable this feature by commenting the following 2 lines or
# - use Apache >= 2.3.9 and replace all L flags by END flags and remove the # - use Apache >= 2.3.9 and replace all L flags by END flags and remove the
# following RewriteCond (best solution) # following RewriteCond (best solution)
RewriteCond %{ENV:REDIRECT_STATUS} ^$ RewriteCond %{ENV:REDIRECT_STATUS} =""
RewriteRule ^index\.php(/(.*)|$) %{ENV:BASE}/$2 [R=301,L] RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L]
# If the requested filename exists, simply serve it. # If the requested filename exists, simply serve it.
# We only want to let Apache serve files and not directories. # We only want to let Apache serve files and not directories.
RewriteCond %{REQUEST_FILENAME} -f
RewriteRule .? - [L]
# Rewrite all other queries to the front controller. # Rewrite all other queries to the front controller.
RewriteRule .? %{ENV:BASE}/index.php [L] RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ %{ENV:BASE}/index.php [L]
</IfModule> </IfModule>
<IfModule !mod_rewrite.c> <IfModule !mod_rewrite.c>
@@ -55,7 +66,7 @@ DirectoryIndex index.php
# When mod_rewrite is not available, we instruct a temporary redirect of # When mod_rewrite is not available, we instruct a temporary redirect of
# the start page to the front controller explicitly so that the website # the start page to the front controller explicitly so that the website
# and the generated links can still be used. # and the generated links can still be used.
RedirectMatch 302 ^/$ /index.php/ RedirectMatch 307 ^/$ /index.php/
# RedirectTemp cannot be used instead # RedirectTemp cannot be used instead
</IfModule> </IfModule>
</IfModule> </IfModule>

View File

@@ -26,7 +26,7 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
#[Route(path: '/actions')] #[Route(path: '/actions')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')] #[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'Actions')] #[OA\Tag(name: 'Actions')]
final class ActionsController extends BaseApiController final class ActionsController extends BaseApiController
{ {

View File

@@ -33,7 +33,7 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/activities')] #[Route(path: '/activities')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')] #[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'Activity')] #[OA\Tag(name: 'Activity')]
final class ActivityController extends BaseApiController final class ActivityController extends BaseApiController
{ {

View File

@@ -19,7 +19,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')] #[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'Default')] #[OA\Tag(name: 'Default')]
final class ConfigurationController extends BaseApiController final class ConfigurationController extends BaseApiController
{ {

View File

@@ -33,7 +33,7 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/customers')] #[Route(path: '/customers')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')] #[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'Customer')] #[OA\Tag(name: 'Customer')]
final class CustomerController extends BaseApiController final class CustomerController extends BaseApiController
{ {

View File

@@ -35,7 +35,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints; use Symfony\Component\Validator\Constraints;
#[Route(path: '/projects')] #[Route(path: '/projects')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')] #[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'Project')] #[OA\Tag(name: 'Project')]
final class ProjectController extends BaseApiController final class ProjectController extends BaseApiController
{ {

View File

@@ -20,7 +20,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')] #[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'Default')] #[OA\Tag(name: 'Default')]
final class StatusController extends BaseApiController final class StatusController extends BaseApiController
{ {

View File

@@ -23,7 +23,7 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/tags')] #[Route(path: '/tags')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')] #[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'Tag')] #[OA\Tag(name: 'Tag')]
final class TagController extends BaseApiController final class TagController extends BaseApiController
{ {

View File

@@ -30,7 +30,7 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/teams')] #[Route(path: '/teams')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')] #[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'Team')] #[OA\Tag(name: 'Team')]
final class TeamController extends BaseApiController final class TeamController extends BaseApiController
{ {

View File

@@ -42,7 +42,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints; use Symfony\Component\Validator\Constraints;
#[Route(path: '/timesheets')] #[Route(path: '/timesheets')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')] #[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'Timesheet')] #[OA\Tag(name: 'Timesheet')]
final class TimesheetController extends BaseApiController final class TimesheetController extends BaseApiController
{ {
@@ -85,14 +85,14 @@ final class TimesheetController extends BaseApiController
#[Rest\QueryParam(name: 'tags', map: true, strict: true, nullable: true, default: [], description: 'List of tag names, e.g. tags[]=bar&tags[]=foo')] #[Rest\QueryParam(name: 'tags', map: true, strict: true, nullable: true, default: [], description: 'List of tag names, e.g. tags[]=bar&tags[]=foo')]
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|begin|end|rate', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, begin, end, rate (default: begin)')] #[Rest\QueryParam(name: 'orderBy', requirements: 'id|begin|end|rate', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, begin, end, rate (default: begin)')]
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: DESC)')] #[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: DESC)')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5)')] #[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records before this date will be included (format: HTML5)')] #[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records before this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'exported', requirements: '0|1', strict: true, nullable: true, description: 'Use this flag if you want to filter for export state. Allowed values: 0=not exported, 1=exported (default: all)')] #[Rest\QueryParam(name: 'exported', requirements: '0|1', strict: true, nullable: true, description: 'Use this flag if you want to filter for export state. Allowed values: 0=not exported, 1=exported (default: all)')]
#[Rest\QueryParam(name: 'active', requirements: '0|1', strict: true, nullable: true, description: 'Filter for running/active records. Allowed values: 0=stopped, 1=active (default: all)')] #[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: '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: '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')]
#[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). Available since Kimai 1.10 and works only for records that were created/updated since then.')] #[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 public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository, ProjectRepository $projectRepository, ActivityRepository $activityRepository, UserRepository $userRepository): Response
{ {
$query = new TimesheetQuery(false); $query = new TimesheetQuery(false);
@@ -398,7 +398,7 @@ final class TimesheetController extends BaseApiController
#[IsGranted('view_own_timesheet')] #[IsGranted('view_own_timesheet')]
#[OA\Response(response: 200, description: 'Returns the collection of recent user activities (always the latest entry of a unique working set grouped by customer, project and activity)', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollectionExpanded')))] #[OA\Response(response: 200, description: 'Returns the collection of recent user activities (always the latest entry of a unique working set grouped by customer, project and activity)', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollectionExpanded')))]
#[Route(methods: ['GET'], path: '/recent', name: 'recent_timesheet')] #[Route(methods: ['GET'], path: '/recent', name: 'recent_timesheet')]
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included. Default: today - 1 year (format: HTML5)')] #[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included. Default: today - 1 year (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries (default: 10)')] #[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries (default: 10)')]
public function recentAction(ParamFetcherInterface $paramFetcher): Response public function recentAction(ParamFetcherInterface $paramFetcher): Response
{ {

View File

@@ -33,7 +33,7 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/users')] #[Route(path: '/users')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')] #[IsGranted('IS_AUTHENTICATED')]
#[OA\Tag(name: 'User')] #[OA\Tag(name: 'User')]
final class UserController extends BaseApiController final class UserController extends BaseApiController
{ {

View File

@@ -127,6 +127,8 @@ final class SystemConfiguration
*/ */
public function offsetExists($offset): bool public function offsetExists($offset): bool
{ {
@trigger_error('The method "SystemConfiguration::offsetExists()" is deprecated, use "has()" instead', E_USER_DEPRECATED);
return $this->has($offset); return $this->has($offset);
} }
@@ -135,6 +137,8 @@ final class SystemConfiguration
*/ */
public function offsetGet($offset): mixed public function offsetGet($offset): mixed
{ {
@trigger_error('The method "SystemConfiguration::offsetGet()" is deprecated, use "find()" instead', E_USER_DEPRECATED);
return $this->find($offset); return $this->find($offset);
} }
@@ -143,16 +147,9 @@ final class SystemConfiguration
*/ */
public function offsetSet(mixed $offset, mixed $value): void public function offsetSet(mixed $offset, mixed $value): void
{ {
$this->set($offset, $value); @trigger_error('The method "SystemConfiguration::offsetSet()" is deprecated, use "set()" instead', E_USER_DEPRECATED);
}
/** $this->set($offset, $value);
* @deprecated since 2.0.35
* @throws \BadMethodCallException
*/
public function offsetUnset(mixed $offset): void
{
throw new \BadMethodCallException('SystemBundleConfiguration does not support offsetUnset()');
} }
// ========== Authentication configurations ========== // ========== Authentication configurations ==========

View File

@@ -17,11 +17,11 @@ class Constants
/** /**
* The current release version * The current release version
*/ */
public const VERSION = '2.14.0'; public const VERSION = '2.15.0';
/** /**
* The current release: major * 10000 + minor * 100 + patch * The current release: major * 10000 + minor * 100 + patch
*/ */
public const VERSION_ID = 21400; public const VERSION_ID = 21500;
/** /**
* The software name * The software name
*/ */

View File

@@ -199,7 +199,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
} }
} }
protected function logException(\Exception $ex): void protected function logException(\Throwable $ex): void
{ {
$this->container->get('logger')->critical($ex->getMessage()); $this->container->get('logger')->critical($ex->getMessage());
} }

View File

@@ -265,6 +265,7 @@ final class DoctorController extends AbstractController
'sys_temp_dir', 'sys_temp_dir',
'date.timezone', 'date.timezone',
'session.gc_maxlifetime', 'session.gc_maxlifetime',
'disable_functions'
]; ];
$settings = []; $settings = [];

View File

@@ -61,7 +61,7 @@ final class PluginController extends AbstractController
$item->expiresAfter(86400); // one day $item->expiresAfter(86400); // one day
return $response->toArray(); return $response->toArray();
} catch (\Exception $exception) { } catch (\Throwable $exception) {
$this->logException($exception); $this->logException($exception);
$this->flashError('Could not download plugin information'); $this->flashError('Could not download plugin information');
} }

View File

@@ -88,12 +88,12 @@ abstract class TimesheetAbstractController extends AbstractController
if ($canSeeRate) { if ($canSeeRate) {
$table->addColumn('hourlyRate', ['class' => 'text-end d-none text-nowrap']); $table->addColumn('hourlyRate', ['class' => 'text-end d-none text-nowrap']);
$table->addColumn('internalRate', ['class' => 'text-end text-nowrap d-none d-md-table-cell']); $table->addColumn('internalRate', ['class' => 'text-end text-nowrap d-none d-xxl-table-cell']);
$table->addColumn('rate', ['class' => 'text-end text-nowrap']); $table->addColumn('rate', ['class' => 'text-end text-nowrap']);
} }
$table->addColumn('customer', ['class' => 'd-none d-md-table-cell']); $table->addColumn('customer', ['class' => 'd-none d-md-table-cell']);
$table->addColumn('project', ['class' => 'd-none d-lg-table-cell']); $table->addColumn('project', ['class' => 'd-none d-xl-table-cell']);
$table->addColumn('activity', ['class' => 'd-none d-xl-table-cell']); $table->addColumn('activity', ['class' => 'd-none d-xl-table-cell']);
$table->addColumn('description', ['class' => 'd-none']); $table->addColumn('description', ['class' => 'd-none']);
$table->addColumn('tags', ['class' => 'd-none badges', 'orderBy' => false]); $table->addColumn('tags', ['class' => 'd-none badges', 'orderBy' => false]);
@@ -103,7 +103,7 @@ abstract class TimesheetAbstractController extends AbstractController
} }
if ($canSeeUsername) { if ($canSeeUsername) {
$table->addColumn('username', ['class' => 'd-none d-sm-table-cell', 'orderBy' => false]); $table->addColumn('username', ['class' => 'd-none d-md-table-cell', 'orderBy' => false]);
} }
$table->addColumn('billable', ['class' => 'text-center d-none w-min', 'orderBy' => false]); $table->addColumn('billable', ['class' => 'text-center d-none w-min', 'orderBy' => false]);

View File

@@ -11,9 +11,9 @@ namespace App\Doctrine\Extensions;
use Doctrine\ORM\Query\AST\Functions\FunctionNode; use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node; use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser; use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker; use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;
final class Date extends FunctionNode final class Date extends FunctionNode
{ {
@@ -26,9 +26,9 @@ final class Date extends FunctionNode
public function parse(Parser $parser): void public function parse(Parser $parser): void
{ {
$parser->match(Lexer::T_IDENTIFIER); $parser->match(TokenType::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS); $parser->match(TokenType::T_OPEN_PARENTHESIS);
$this->value = $parser->ArithmeticPrimary(); $this->value = $parser->ArithmeticPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS); $parser->match(TokenType::T_CLOSE_PARENTHESIS);
} }
} }

View File

@@ -11,9 +11,9 @@ namespace App\Doctrine\Extensions;
use Doctrine\ORM\Query\AST\Functions\FunctionNode; use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node; use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser; use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker; use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;
final class Day extends FunctionNode final class Day extends FunctionNode
{ {
@@ -26,9 +26,9 @@ final class Day extends FunctionNode
public function parse(Parser $parser): void public function parse(Parser $parser): void
{ {
$parser->match(Lexer::T_IDENTIFIER); $parser->match(TokenType::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS); $parser->match(TokenType::T_OPEN_PARENTHESIS);
$this->value = $parser->ArithmeticPrimary(); $this->value = $parser->ArithmeticPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS); $parser->match(TokenType::T_CLOSE_PARENTHESIS);
} }
} }

View File

@@ -11,9 +11,9 @@ namespace App\Doctrine\Extensions;
use Doctrine\ORM\Query\AST\Functions\FunctionNode; use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node; use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser; use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker; use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;
final class Month extends FunctionNode final class Month extends FunctionNode
{ {
@@ -26,9 +26,9 @@ final class Month extends FunctionNode
public function parse(Parser $parser): void public function parse(Parser $parser): void
{ {
$parser->match(Lexer::T_IDENTIFIER); $parser->match(TokenType::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS); $parser->match(TokenType::T_OPEN_PARENTHESIS);
$this->value = $parser->ArithmeticPrimary(); $this->value = $parser->ArithmeticPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS); $parser->match(TokenType::T_CLOSE_PARENTHESIS);
} }
} }

View File

@@ -11,9 +11,9 @@ namespace App\Doctrine\Extensions;
use Doctrine\ORM\Query\AST\Functions\FunctionNode; use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node; use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Lexer;
use Doctrine\ORM\Query\Parser; use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker; use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;
final class Year extends FunctionNode final class Year extends FunctionNode
{ {
@@ -26,9 +26,9 @@ final class Year extends FunctionNode
public function parse(Parser $parser): void public function parse(Parser $parser): void
{ {
$parser->match(Lexer::T_IDENTIFIER); $parser->match(TokenType::T_IDENTIFIER);
$parser->match(Lexer::T_OPEN_PARENTHESIS); $parser->match(TokenType::T_OPEN_PARENTHESIS);
$this->value = $parser->ArithmeticPrimary(); $this->value = $parser->ArithmeticPrimary();
$parser->match(Lexer::T_CLOSE_PARENTHESIS); $parser->match(TokenType::T_CLOSE_PARENTHESIS);
} }
} }

View File

@@ -12,6 +12,7 @@ namespace App\Doctrine;
use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException; use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\DateTimeType; use Doctrine\DBAL\Types\DateTimeType;
use Doctrine\DBAL\Types\Types;
final class UTCDateTimeType extends DateTimeType final class UTCDateTimeType extends DateTimeType
{ {
@@ -67,7 +68,7 @@ final class UTCDateTimeType extends DateTimeType
if (!$converted) { if (!$converted) {
throw ConversionException::conversionFailedFormat( throw ConversionException::conversionFailedFormat(
$value, $value,
$this->getName(), Types::DATETIME_MUTABLE,
$platform->getDateTimeFormatString() $platform->getDateTimeFormatString()
); );
} }

View File

@@ -15,6 +15,8 @@ use App\Invoice\InvoiceModel;
final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
{ {
private const DATE_PROCESS_FORMAT = 'Y-m-d h:i:s';
private InvoiceModel $model; private InvoiceModel $model;
public function setInvoiceModel(InvoiceModel $model): void public function setInvoiceModel(InvoiceModel $model): void
@@ -75,17 +77,24 @@ final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
'entry.duration_format' => $formatter->getFormattedDuration($item->getDuration()), 'entry.duration_format' => $formatter->getFormattedDuration($item->getDuration()),
'entry.duration_decimal' => $formatter->getFormattedDecimalDuration($item->getDuration()), 'entry.duration_decimal' => $formatter->getFormattedDecimalDuration($item->getDuration()),
'entry.duration_minutes' => (int) ($item->getDuration() / 60), 'entry.duration_minutes' => (int) ($item->getDuration() / 60),
'entry.begin' => $formatter->getFormattedDateTime($begin),
'entry.begin_time' => $formatter->getFormattedTime($begin),
'entry.begin_timestamp' => $begin->getTimestamp(),
'entry.end' => $formatter->getFormattedDateTime($end),
'entry.end_time' => $formatter->getFormattedTime($end),
'entry.end_timestamp' => $end->getTimestamp(),
'entry.date' => $formatter->getFormattedDateTime($begin),
'entry.week' => \intval($begin->format('W')),
'entry.weekyear' => $begin->format('o'),
]; ];
if ($begin !== null) {
$values['entry.begin'] = $formatter->getFormattedDateTime($begin);
$values['entry.begin_time'] = $formatter->getFormattedTime($begin);
$values['entry.begin_timestamp'] = $begin->getTimestamp();
$values['entry.date'] = $formatter->getFormattedDateTime($begin);
$values['entry.date_process'] = $begin->format(self::DATE_PROCESS_FORMAT); // since 2.14
$values['entry.week'] = \intval($begin->format('W'));
$values['entry.weekyear'] = $begin->format('o');
}
if ($end !== null) {
$values['entry.end'] = $formatter->getFormattedDateTime($end);
$values['entry.end_time'] = $formatter->getFormattedTime($end);
$values['entry.end_timestamp'] = $end->getTimestamp();
}
if (null !== $user) { if (null !== $user) {
$values = array_merge($values, [ $values = array_merge($values, [
'entry.user_id' => $user->getId(), 'entry.user_id' => $user->getId(),

View File

@@ -66,6 +66,8 @@ final class InvoiceModelActivityHydrator implements InvoiceModelHydrator
$prefix . 'id' => $activity->getId(), $prefix . 'id' => $activity->getId(),
$prefix . 'name' => $activity->getName() ?? '', $prefix . 'name' => $activity->getName() ?? '',
$prefix . 'comment' => $activity->getComment() ?? '', $prefix . 'comment' => $activity->getComment() ?? '',
$prefix . 'number' => $activity->getNumber() ?? '',
$prefix . 'invoice_text' => $activity->getInvoiceText() ?? '',
]; ];
if ($model->getQuery()?->getEnd() !== null) { if ($model->getQuery()?->getEnd() !== null) {

View File

@@ -79,6 +79,8 @@ final class InvoiceModelProjectHydrator implements InvoiceModelHydrator
$prefix . 'budget_time' => $project->getTimeBudget(), $prefix . 'budget_time' => $project->getTimeBudget(),
$prefix . 'budget_time_decimal' => $formatter->getFormattedDecimalDuration($project->getTimeBudget()), $prefix . 'budget_time_decimal' => $formatter->getFormattedDecimalDuration($project->getTimeBudget()),
$prefix . 'budget_time_minutes' => (int) ($project->getTimeBudget() / 60), $prefix . 'budget_time_minutes' => (int) ($project->getTimeBudget() / 60),
$prefix . 'number' => $project->getNumber() ?? '',
$prefix . 'invoice_text' => $project->getInvoiceText() ?? '',
]; ];
if ($model->getQuery()?->getEnd() !== null) { if ($model->getQuery()?->getEnd() !== null) {

View File

@@ -17,7 +17,14 @@ use App\Entity\Project;
class ActivityQuery extends ProjectQuery class ActivityQuery extends ProjectQuery
{ {
public const ACTIVITY_ORDER_ALLOWED = [ public const ACTIVITY_ORDER_ALLOWED = [
'name', 'description' => 'comment', 'customer', 'project', 'budget', 'timeBudget', 'visible' 'name',
'description' => 'comment',
'activity_number' => 'number',
'customer',
'project',
'budget',
'timeBudget',
'visible'
]; ];
/** /**

View File

@@ -16,8 +16,17 @@ class ProjectQuery extends BaseQuery implements VisibilityInterface
use VisibilityTrait; use VisibilityTrait;
public const PROJECT_ORDER_ALLOWED = [ public const PROJECT_ORDER_ALLOWED = [
'name', 'description' => 'comment', 'customer', 'orderNumber', 'orderDate', 'name',
'project_start', 'project_end', 'budget', 'timeBudget', 'visible' 'description' => 'comment',
'project_number' => 'number',
'customer',
'orderNumber',
'orderDate',
'project_start',
'project_end',
'budget',
'timeBudget',
'visible'
]; ];
/** /**

View File

@@ -62,7 +62,11 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{ {
if (!($user instanceof User)) { if (!($user instanceof User) || !$user->isInternalUser()) {
return;
}
if ($user->getPassword() === $newHashedPassword) {
return; return;
} }

View File

@@ -29,7 +29,9 @@ final class SamlAuthenticationSuccessHandler extends DefaultAuthenticationSucces
} }
$relayState = $request->get('RelayState'); $relayState = $request->get('RelayState');
if (null !== $relayState && $relayState !== $this->httpUtils->generateUri($request, $this->options['login_path'])) { $loginUrl = $this->httpUtils->generateUri($request, $this->options['login_path']);
if ($relayState !== null && $relayState !== '' && $relayState !== $loginUrl) {
return $relayState; return $relayState;
} }

View File

@@ -52,9 +52,9 @@ final class TimesheetStatisticService
->addSelect('DAY(t.date) as day') ->addSelect('DAY(t.date) as day')
->addSelect('MONTH(t.date) as month') ->addSelect('MONTH(t.date) as month')
->addSelect('YEAR(t.date) as year') ->addSelect('YEAR(t.date) as year')
->where($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->between('t.date', ':begin', ':end')) ->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
->andWhere($qb->expr()->in('t.user', ':user')) ->andWhere($qb->expr()->in('t.user', ':user'))
->andWhere($qb->expr()->isNotNull('t.end'))
->setParameter('begin', $begin->format('Y-m-d')) ->setParameter('begin', $begin->format('Y-m-d'))
->setParameter('end', $end->format('Y-m-d')) ->setParameter('end', $end->format('Y-m-d'))
->setParameter('user', $users) ->setParameter('user', $users)
@@ -124,9 +124,9 @@ final class TimesheetStatisticService
->addSelect('IDENTITY(t.project) as project') ->addSelect('IDENTITY(t.project) as project')
->addSelect('IDENTITY(t.activity) as activity') ->addSelect('IDENTITY(t.activity) as activity')
->addSelect('DATE(t.date) as date') ->addSelect('DATE(t.date) as date')
->where($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->between('t.date', ':begin', ':end')) ->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
->andWhere($qb->expr()->in('t.user', ':user')) ->andWhere($qb->expr()->in('t.user', ':user'))
->andWhere($qb->expr()->isNotNull('t.end'))
->setParameter('begin', $begin->format('Y-m-d')) ->setParameter('begin', $begin->format('Y-m-d'))
->setParameter('end', $end->format('Y-m-d')) ->setParameter('end', $end->format('Y-m-d'))
->setParameter('user', $users) ->setParameter('user', $users)
@@ -200,9 +200,9 @@ final class TimesheetStatisticService
->addSelect('IDENTITY(t.activity) as activity') ->addSelect('IDENTITY(t.activity) as activity')
->addSelect('YEAR(t.date) as year') ->addSelect('YEAR(t.date) as year')
->addSelect('MONTH(t.date) as month') ->addSelect('MONTH(t.date) as month')
->where($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->between('t.date', ':begin', ':end')) ->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
->andWhere($qb->expr()->in('t.user', ':user')) ->andWhere($qb->expr()->in('t.user', ':user'))
->andWhere($qb->expr()->isNotNull('t.end'))
->setParameter('begin', $begin->format('Y-m-d')) ->setParameter('begin', $begin->format('Y-m-d'))
->setParameter('end', $end->format('Y-m-d')) ->setParameter('end', $end->format('Y-m-d'))
->setParameter('user', $users) ->setParameter('user', $users)
@@ -292,9 +292,9 @@ final class TimesheetStatisticService
->addSelect('MONTH(t.date) as month') ->addSelect('MONTH(t.date) as month')
->addSelect('YEAR(t.date) as year') ->addSelect('YEAR(t.date) as year')
->addSelect('IDENTITY(t.user) as user') ->addSelect('IDENTITY(t.user) as user')
->where($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->between('t.date', ':begin', ':end')) ->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
->andWhere($qb->expr()->in('t.user', ':user')) ->andWhere($qb->expr()->in('t.user', ':user'))
->andWhere($qb->expr()->isNotNull('t.end'))
->setParameter('begin', $begin->format('Y-m-d')) ->setParameter('begin', $begin->format('Y-m-d'))
->setParameter('end', $end->format('Y-m-d')) ->setParameter('end', $end->format('Y-m-d'))
->setParameter('user', $users) ->setParameter('user', $users)

View File

@@ -1,206 +0,0 @@
<?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\Widget\DataProvider;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\Statistic\Day;
use App\Repository\TimesheetRepository;
use DateTime;
use DateTimeInterface;
/**
* This class should really be deleted and replaced by TimesheetStatisticService::getDailyStatistics()
* @deprecated since 2.0
* @codeCoverageIgnore
* @internal
* @final
*/
class DailyWorkingTimeChartProvider
{
public function __construct(private readonly TimesheetRepository $repository)
{
}
/**
* In case this method is called with one timezone and the results are from another timezone,
* it might return rows outside the time-range.
*
* @return array<mixed>
*/
protected function getDailyData(DateTimeInterface $begin, DateTimeInterface $end, ?User $user = null): array
{
$qb = $this->repository->createQueryBuilder('t');
$or = $qb->expr()->orX();
$or->add($qb->expr()->between(':begin', 't.begin', 't.end'));
$or->add($qb->expr()->between(':end', 't.begin', 't.end'));
$or->add($qb->expr()->between('t.begin', ':begin', ':end'));
$or->add($qb->expr()->between('t.end', ':begin', ':end'));
$qb->select('t, p, a, c')
->andWhere($qb->expr()->isNotNull('t.end'))
->andWhere($or)
->orderBy('t.begin', 'DESC')
->setParameter('begin', $begin)
->setParameter('end', $end)
->leftJoin('t.activity', 'a')
->leftJoin('t.project', 'p')
->leftJoin('p.customer', 'c')
;
if (null !== $user) {
$qb
->andWhere($qb->expr()->eq('t.user', ':user'))
->setParameter('user', $user)
;
}
$timesheets = $qb->getQuery()->getResult();
$results = [];
/** @var Timesheet $result */
foreach ($timesheets as $result) {
/** @var DateTime $beginTmp */
$beginTmp = $result->getBegin();
/** @var DateTime $endTmp */
$endTmp = $result->getEnd();
$dateKeyEnd = $endTmp->format('Ymd');
do {
$dateKey = $beginTmp->format('Ymd');
if ($dateKey !== $dateKeyEnd) {
$newDateBegin = clone $beginTmp;
$newDateBegin->add(new \DateInterval('P1D'));
// overlapping records should always start at midnight
$newDateBegin->setTime(0, 0, 0);
} else {
$newDateBegin = clone $endTmp;
}
// make sure to exclude entries that are outside the requested time-range:
// these entries can exist if you have long running entries that started before $begin
// for statistical reasons we have to include everything between $begin and $end while
// excluding everything that is outside of that range
// --------------------------------------------------------------------------------------
// Be aware that this will NOT filter every record, in case there is a timezone mismatch between the
// begin/end dates and the ones from the database (eg. recorded in UTC) - which might actually be
// before $begin (which happens thanks to the timezone conversion when querying the database)
if ($newDateBegin > $begin && $beginTmp < $end) {
if (!isset($results[$dateKey])) {
$results[$dateKey] = [
'rate' => 0,
'duration' => 0,
'billable' => 0, // duration
'month' => $beginTmp->format('n'),
'year' => $beginTmp->format('Y'),
'day' => $beginTmp->format('j'),
'details' => []
];
}
$duration = $newDateBegin->getTimestamp() - $beginTmp->getTimestamp();
$durationPercent = 0;
if ($result->getDuration() !== null && $result->getDuration() > 0) {
$durationPercent = $duration / $result->getDuration();
}
$rate = $result->getRate() * $durationPercent;
$results[$dateKey]['rate'] += $rate;
$results[$dateKey]['duration'] += $duration;
if ($result->isBillable()) {
$results[$dateKey]['billable'] += $duration;
}
$detailsId =
$result->getProject()->getCustomer()->getId()
. '_' . $result->getProject()->getId()
. '_' . $result->getActivity()->getId()
;
if (!isset($results[$dateKey]['details'][$detailsId])) {
$results[$dateKey]['details'][$detailsId] = [
'project' => $result->getProject(),
'activity' => $result->getActivity(),
'duration' => 0,
'rate' => 0,
'billable' => 0, // duration
];
}
$results[$dateKey]['details'][$detailsId]['duration'] += $duration;
$results[$dateKey]['details'][$detailsId]['rate'] += $rate;
if ($result->isBillable()) {
$results[$dateKey]['details'][$detailsId]['billable'] += $duration;
}
}
$beginTmp = $newDateBegin;
// yes, we only want to compare the day, not the time
if ((int) $end->format('Ymd') < (int) $newDateBegin->format('Ymd')) {
break;
}
} while ($dateKey !== $dateKeyEnd);
}
ksort($results);
foreach ($results as $key => $value) {
$results[$key]['details'] = array_values($results[$key]['details']);
}
return array_values($results);
}
/**
* @deprecated since 2.0 - use TimesheetStatisticService::getDailyStatistics() instead
*
* @param User|null $user
* @param DateTimeInterface $begin
* @param DateTimeInterface $end
* @return Day[]
* @throws \Exception
*/
public function getData(?User $user, DateTimeInterface $begin, DateTimeInterface $end): array
{
/** @var Day[] $days */
$days = [];
// prefill the array
$tmp = DateTime::createFromInterface($end);
$until = (int) $begin->format('Ymd');
while ((int) $tmp->format('Ymd') >= $until) {
$last = clone $tmp;
$days[$last->format('Ymd')] = new Day($last, 0, 0.00);
$tmp->modify('-1 day');
}
$results = $this->getDailyData($begin, $end, $user);
foreach ($results as $statRow) {
$dateTime = DateTime::createFromInterface($begin);
$dateTime->setDate($statRow['year'], $statRow['month'], $statRow['day']);
$dateTime->setTime(0, 0, 0);
$day = new Day($dateTime, (int) $statRow['duration'], (float) $statRow['rate']);
$day->setTotalDurationBillable($statRow['billable']);
$day->setDetails($statRow['details']);
$dateKey = $dateTime->format('Ymd');
// make sure entries from other timezones are filtered
if (!\array_key_exists($dateKey, $days)) {
continue;
}
$days[$dateKey] = $day;
}
ksort($days);
return array_values($days);
}
}

View File

@@ -11,17 +11,21 @@ namespace App\Widget\Type;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\Statistic\Day;
use App\Repository\TimesheetRepository;
use App\Timesheet\DateTimeFactory; use App\Timesheet\DateTimeFactory;
use App\Widget\DataProvider\DailyWorkingTimeChartProvider;
use App\Widget\WidgetInterface; use App\Widget\WidgetInterface;
use DateTime; use DateTime;
use DateTimeInterface;
/** /**
* This is rendered inside the PaginatedWorkingTimeChart. * This is rendered inside the PaginatedWorkingTimeChart.
*/ */
final class DailyWorkingTimeChart extends AbstractWidget final class DailyWorkingTimeChart extends AbstractWidget
{ {
public function __construct(private DailyWorkingTimeChartProvider $dailyWorkingTimeChartProvider) public function __construct(private readonly TimesheetRepository $repository)
{ {
} }
@@ -47,7 +51,7 @@ final class DailyWorkingTimeChart extends AbstractWidget
/** /**
* @param array<string, string|bool|int|null|array<string, mixed>> $options * @param array<string, string|bool|int|null|array<string, mixed>> $options
@return array<string, string|bool|int|null|array<string, mixed>> * @return array<string, string|bool|int|null|array<string, mixed>>
*/ */
public function getOptions(array $options = []): array public function getOptions(array $options = []): array
{ {
@@ -71,7 +75,7 @@ final class DailyWorkingTimeChart extends AbstractWidget
$begin = $options['begin']; $begin = $options['begin'];
if (!($begin instanceof \DateTimeInterface)) { if (!($begin instanceof \DateTimeInterface)) {
if (\is_string($begin)) { if (\is_string($begin)) {
$begin = new DateTime($begin, new \DateTimeZone($user->getTimezone())); $begin = new \DateTimeImmutable($begin, new \DateTimeZone($user->getTimezone()));
} else { } else {
$begin = $dateTimeFactory->getStartOfWeek(); $begin = $dateTimeFactory->getStartOfWeek();
} }
@@ -80,14 +84,14 @@ final class DailyWorkingTimeChart extends AbstractWidget
$end = $options['end']; $end = $options['end'];
if (!($end instanceof \DateTimeInterface)) { if (!($end instanceof \DateTimeInterface)) {
if (\is_string($end)) { if (\is_string($end)) {
$end = new DateTime($end, new \DateTimeZone($user->getTimezone())); $end = new \DateTimeImmutable($end, new \DateTimeZone($user->getTimezone()));
} else { } else {
$end = $dateTimeFactory->getEndOfWeek($begin); $end = $dateTimeFactory->getEndOfWeek($begin);
} }
} }
$activities = []; $activities = [];
$statistics = $this->dailyWorkingTimeChartProvider->getData($user, $begin, $end); $statistics = $this->getPreparedData($user, $begin, $end);
foreach ($statistics as $day) { foreach ($statistics as $day) {
foreach ($day->getDetails() as $entry) { foreach ($day->getDetails() as $entry) {
@@ -125,4 +129,120 @@ final class DailyWorkingTimeChart extends AbstractWidget
{ {
return 'widget/widget-dailyworkingtimechart.html.twig'; return 'widget/widget-dailyworkingtimechart.html.twig';
} }
/**
* @return list<array{'duration': int, 'billable': int, 'month': numeric-string, 'year': numeric-string, 'day': numeric-string, 'details': array<int|string, array<mixed>>}>
*/
private function getDailyData(DateTimeInterface $begin, DateTimeInterface $end, User $user): array
{
$qb = $this->repository->createQueryBuilder('t');
$qb->select('t, p, a, c')
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
->andWhere($qb->expr()->eq('t.user', ':user'))
->andWhere($qb->expr()->isNotNull('t.end'))
->setParameter('begin', $begin->format('Y-m-d'))
->setParameter('end', $end->format('Y-m-d'))
->setParameter('user', $user)
->leftJoin('t.activity', 'a')
->leftJoin('t.project', 'p')
->leftJoin('p.customer', 'c')
;
$timesheets = $qb->getQuery()->getResult();
$results = [];
/** @var Timesheet $result */
foreach ($timesheets as $result) {
/** @var DateTime $beginTmp */
$beginTmp = $result->getBegin();
$dateKey = $beginTmp->format('Ymd');
if (!isset($results[$dateKey])) {
$results[$dateKey] = [
'duration' => 0,
'billable' => 0, // duration
'month' => $beginTmp->format('n'),
'year' => $beginTmp->format('Y'),
'day' => $beginTmp->format('j'),
'details' => []
];
}
$duration = $result->getDuration() ?? 0;
$results[$dateKey]['duration'] += $duration;
if ($result->isBillable()) {
$results[$dateKey]['billable'] += $duration;
}
$detailsId =
$result->getProject()->getCustomer()->getId()
. '_' . ($result->getProject()?->getId() ?? '')
. '_' . ($result->getActivity()?->getId() ?? '')
;
if (!isset($results[$dateKey]['details'][$detailsId])) {
$results[$dateKey]['details'][$detailsId] = [
'project' => $result->getProject(),
'activity' => $result->getActivity(),
'duration' => 0,
'billable' => 0, // duration
];
}
$results[$dateKey]['details'][$detailsId]['duration'] += $duration;
if ($result->isBillable()) {
$results[$dateKey]['details'][$detailsId]['billable'] += $duration;
}
}
ksort($results);
foreach ($results as $key => $value) {
$results[$key]['details'] = array_values($value['details']);
}
return array_values($results);
}
/**
* @return Day[]
* @throws \Exception
*/
private function getPreparedData(User $user, DateTimeInterface $begin, DateTimeInterface $end): array
{
/** @var Day[] $days */
$days = [];
// prefill the array
$tmp = DateTime::createFromInterface($end);
$until = (int) $begin->format('Ymd');
while ((int) $tmp->format('Ymd') >= $until) {
$last = clone $tmp;
$days[$last->format('Ymd')] = new Day($last, 0, 0.00);
$tmp->modify('-1 day');
}
// TODO replace with TimesheetStatisticService::getDailyStatistics()
$results = $this->getDailyData($begin, $end, $user);
foreach ($results as $statRow) {
$dateTime = DateTime::createFromInterface($begin);
$dateTime->setDate((int) $statRow['year'], (int) $statRow['month'], (int) $statRow['day']);
$dateTime->setTime(0, 0, 0);
$day = new Day($dateTime, (int) $statRow['duration'], 0.00); // rate is not used in frontend
$day->setTotalDurationBillable($statRow['billable']);
$day->setDetails($statRow['details']);
$dateKey = $dateTime->format('Ymd');
// make sure entries from other timezones are filtered
if (!\array_key_exists($dateKey, $days)) {
continue;
}
$days[$dateKey] = $day;
}
ksort($days);
return array_values($days);
}
} }

View File

@@ -85,8 +85,6 @@
{{- get_title() -}} {{- get_title() -}}
{% endblock %} {% endblock %}
{% block page_actions_class %}col-auto ms-auto d-print-none{% endblock %}
{% block footer %} {% block footer %}
{% if 'dashboard' in app.request.attributes.get('_route') %} {% if 'dashboard' in app.request.attributes.get('_route') %}
{{ parent() }} {{ parent() }}

View File

@@ -11,54 +11,35 @@
<img onclick="document.getElementById('totp_secret').classList.toggle('d-none');" data-toggle="tooltip" title="Click to show code" alt="TOTP QR Code" style="max-width: 200px; max-height: 200px;" src="{{ qr_code.dataUri }}" /> <img onclick="document.getElementById('totp_secret').classList.toggle('d-none');" data-toggle="tooltip" title="Click to show code" alt="TOTP QR Code" style="max-width: 200px; max-height: 200px;" src="{{ qr_code.dataUri }}" />
<span id="totp_secret" style="display: block" class="d-none">{{ secret }}</span> <span id="totp_secret" style="display: block" class="d-none">{{ secret }}</span>
</p> </p>
{% if user.totpAuthenticationEnabled %} {% if not user.totpAuthenticationEnabled %}
<div class="row mb-3">
<div class="form-selectgroup form-selectgroup-boxes d-flex">
<label class="form-selectgroup-item flex-column">
<input type="radio" id="user_two_factor_enable" name="user_two_factor[enabled]" class="form-selectgroup-input" value="1" checked="checked">
<div class="form-selectgroup-label d-flex align-items-center p-3">
<div class="me-3">
<span class="form-selectgroup-check"></span>
</div>
<div>
<label class="form-check-label" for="user_two_factor_enable">{{ 'activated'|trans }}</label>
</div>
</div>
</label>
<label class="form-selectgroup-item flex-column">
<input type="radio" id="user_two_factor_disable" name="user_two_factor[enabled]" class="form-selectgroup-input" value="0">
<div class="form-selectgroup-label d-flex align-items-center p-3">
<div class="me-3">
<span class="form-selectgroup-check"></span>
</div>
<div>
<label class="form-check-label" for="user_two_factor_disable">{{ 'deactivated'|trans }}</label>
</div>
</div>
</label>
</div>
</div>
{{ form_start(deactivate, {'attr': {'id': 'user_two_factor_form'}}) }}
{{ form_end(deactivate) }}
{% else %}
{{ form_start(form, {'attr': {'id': 'user_two_factor_form'}}) }} {{ form_start(form, {'attr': {'id': 'user_two_factor_form'}}) }}
<p>{{ 'profile.2fa_confirmation'|trans }}</p> <p>{{ 'profile.2fa_confirmation'|trans }}</p>
{{ form_widget(form) }} {{ form_widget(form) }}
{{ form_end(form) }} {{ form_end(form) }}
{% else %}
<strong><i class="{{ 'success'|icon(true) }} text-success"></i> {{ 'activated'|trans }}</strong>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="card-footer"> <div class="card-footer">
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" id="user_two_factor_button" /> {% if user.totpAuthenticationEnabled %}
{{ form_start(deactivate, {'attr': {'id': 'user_two_factor_form'}}) }}
<input type="submit" value="{{ 'deactivate'|trans }}" class="btn btn-warning" id="user_two_factor_button" />
{{ form_end(deactivate) }}
{% else %}
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" id="user_two_factor_button" />
{% endif %}
</div> </div>
{% endblock %} {% endblock %}
{% block javascripts %} {% block javascripts %}
{{ parent() }} {{ parent() }}
{% if not user.totpAuthenticationEnabled %}
<script> <script>
document.getElementById('user_two_factor_button').addEventListener('click', function(){ document.getElementById('user_two_factor_button').addEventListener('click', function(){
document.getElementById('user_two_factor_form').submit(); document.getElementById('user_two_factor_form').submit();
}); });
</script> </script>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -5,20 +5,20 @@
<div class="card-body"> <div class="card-body">
<div class="row mb-3"> <div class="row mb-3">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p> <p>
{{ 'api_password.intro'|trans }} {{ 'api_password.intro'|trans }}
</p> </p>
<p> <p>
<strong>URL</strong>: {{ url('api.swagger_ui', {}, false)|replace({'/doc': ''}) }}</li> <strong>URL</strong>: {{ url('api.swagger_ui', {}, false)|replace({'/doc': ''}) }}
</p> </p>
</div> </div>
<div class="col-12 col-md-6 text-md-end"> <div class="col-12 col-md-6 text-md-end">
<a class="btn action-create modal-ajax-form" href="{{ path('user_profile_access_token', {'username': user.userIdentifier}) }}">{{ icon('create', true) }} {{ 'create'|trans }}</a> <a class="btn action-create modal-ajax-form" href="{{ path('user_profile_access_token', {'username': user.userIdentifier}) }}">{{ icon('create', true) }} {{ 'create'|trans }}</a>
<a class="btn" target="_blank" href="{{ path('api.swagger_ui') }}" data-toggle="tooltip" title="Swagger API Docs">{{ icon('documentation', true) }} Swagger Docs</a> <a class="btn" target="_blank" href="{{ path('api.swagger_ui') }}">{{ icon('documentation', true) }} Swagger Docs</a>
<a class="btn" target="_blank" href="{{ 'rest-api.html'|docu_link }}" data-toggle="tooltip" title="{{ 'help'|trans }}">{{ icon('help', true) }} {{ 'help'|trans }}</a> <a class="btn" target="_blank" href="{{ 'rest-api.html'|docu_link }}">{{ icon('help', true) }} {{ 'help'|trans }}</a>
</div> </div>
</div> </div>
{% if created_token is not null %} {% if created_token is not null %}
@@ -39,7 +39,7 @@
{% endif %} {% endif %}
{% if access_tokens|length > 0 %} {% if access_tokens|length > 0 %}
<table class="table"> <table class="table table-vcenter">
<thead> <thead>
<tr> <tr>
<th>{{ 'name'|trans }}</th> <th>{{ 'name'|trans }}</th>
@@ -81,6 +81,7 @@
{% embed '@theme/embeds/collapsible.html.twig' with {id: 'activity_invoice_settings'} %} {% embed '@theme/embeds/collapsible.html.twig' with {id: 'activity_invoice_settings'} %}
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{% block title %}{{ 'password'|trans }}{% endblock %} {% block title %}{{ 'password'|trans }}{% endblock %}
{% block box_class %}mt-3{% endblock %}
{% block body %} {% block body %}
{{ widgets.alert('danger', 'api_password_deprecated'|trans) }} {{ widgets.alert('danger', 'api_password_deprecated'|trans) }}
{{ form_start(form) }} {{ form_start(form) }}

View File

@@ -163,15 +163,6 @@ class SystemConfigurationTest extends TestCase
$this->assertEquals('2020-03-27', $sut->getFinancialYearStart()); $this->assertEquals('2020-03-27', $sut->getFinancialYearStart());
} }
public function testOffsetUnsetThrowsException(): void
{
$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('SystemBundleConfiguration does not support offsetUnset()');
$sut = $this->getSut($this->getDefaultSettings(), []);
$sut->offsetUnset('dfsdf');
}
public function testUnknownConfigs(): void public function testUnknownConfigs(): void
{ {
$sut = $this->getSut($this->getDefaultSettings(), [ $sut = $this->getSut($this->getDefaultSettings(), [

View File

@@ -18,11 +18,11 @@ use App\Tests\Mocks\Saml\SamlAuthFactoryFactory;
use App\Tests\Mocks\SystemConfigurationFactory; use App\Tests\Mocks\SystemConfigurationFactory;
use OneLogin\Saml2\Auth; use OneLogin\Saml2\Auth;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
/** /**
* @group integration * @group integration
@@ -127,7 +127,7 @@ class SamlControllerTest extends TestCase
$request = new Request(); $request = new Request();
$request->setSession($this->createMock(SessionInterface::class)); $request->setSession($this->createMock(SessionInterface::class));
$request->attributes->set(Security::AUTHENTICATION_ERROR, new \Exception('My test error')); $request->attributes->set(SecurityRequestAttributes::AUTHENTICATION_ERROR, new \Exception('My test error'));
$factory = $this->getMockBuilder(SamlAuthFactory::class)->disableOriginalConstructor()->getMock(); $factory = $this->getMockBuilder(SamlAuthFactory::class)->disableOriginalConstructor()->getMock();

View File

@@ -35,7 +35,6 @@ class InvoiceTemplateTest extends TestCase
self::assertEquals('default', $sut->getNumberGenerator()); self::assertEquals('default', $sut->getNumberGenerator());
self::assertEquals('default', $sut->getRenderer()); self::assertEquals('default', $sut->getRenderer());
self::assertEquals(30, $sut->getDueDays()); self::assertEquals(30, $sut->getDueDays());
self::assertTrue($sut->isDecimalDuration());
} }
public function testSetNullForOptionalValues(): void public function testSetNullForOptionalValues(): void

View File

@@ -11,6 +11,7 @@ namespace App\Tests\Export\Spreadsheet\CellFormatter;
use App\Export\Spreadsheet\CellFormatter\CellFormatterInterface; use App\Export\Spreadsheet\CellFormatter\CellFormatterInterface;
use PhpOffice\PhpSpreadsheet\Cell\Cell; use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\CellAddress;
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Style; use PhpOffice\PhpSpreadsheet\Style\Style;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -42,7 +43,7 @@ abstract class AbstractFormatterTest extends TestCase
$sut->setFormattedValue($worksheet, 1, 1, $this->getActualValue()); $sut->setFormattedValue($worksheet, 1, 1, $this->getActualValue());
$cell = $worksheet->getCell([1, 1]); $cell = $worksheet->getCell([1, 1]);
$this->assertCellValue($cell); $this->assertCellValue($cell);
$this->assertCellStyle($worksheet->getStyleByColumnAndRow(1, 1)); $this->assertCellStyle($worksheet->getStyle(CellAddress::fromColumnAndRow(1, 1)));
} }
public function testSetNull(): void public function testSetNull(): void

View File

@@ -52,6 +52,6 @@ class DateFormatterTest extends AbstractFormatterTest
public function assertCellStyle(Style $style): void public function assertCellStyle(Style $style): void
{ {
self::assertEquals(NumberFormat::FORMAT_DATE_YYYYMMDD2, $style->getNumberFormat()->getFormatCode()); self::assertEquals(NumberFormat::FORMAT_DATE_YYYYMMDD, $style->getNumberFormat()->getFormatCode());
} }
} }

View File

@@ -63,6 +63,7 @@ class InvoiceItemDefaultHydratorTest extends TestCase
'entry.end_time', 'entry.end_time',
'entry.end_timestamp', 'entry.end_timestamp',
'entry.date', 'entry.date',
'entry.date_process',
'entry.week', 'entry.week',
'entry.weekyear', 'entry.weekyear',
'entry.user_id', 'entry.user_id',

View File

@@ -37,6 +37,8 @@ class InvoiceModelActivityHydratorTest extends TestCase
'activity.id', 'activity.id',
'activity.name', 'activity.name',
'activity.comment', 'activity.comment',
'activity.number',
'activity.invoice_text',
'activity.meta.foo-activity', 'activity.meta.foo-activity',
'activity.budget_open', 'activity.budget_open',
'activity.budget_open_plain', 'activity.budget_open_plain',
@@ -45,6 +47,8 @@ class InvoiceModelActivityHydratorTest extends TestCase
'activity.1.id', 'activity.1.id',
'activity.1.name', 'activity.1.name',
'activity.1.comment', 'activity.1.comment',
'activity.1.number',
'activity.1.invoice_text',
'activity.1.meta.foo-activity', 'activity.1.meta.foo-activity',
'activity.1.budget_open', 'activity.1.budget_open',
'activity.1.budget_open_plain', 'activity.1.budget_open_plain',

View File

@@ -37,6 +37,8 @@ class InvoiceModelProjectHydratorTest extends TestCase
'project.id', 'project.id',
'project.name', 'project.name',
'project.comment', 'project.comment',
'project.number',
'project.invoice_text',
'project.order_date', 'project.order_date',
'project.order_number', 'project.order_number',
'project.meta.foo-project', 'project.meta.foo-project',
@@ -55,6 +57,8 @@ class InvoiceModelProjectHydratorTest extends TestCase
'project.1.id', 'project.1.id',
'project.1.name', 'project.1.name',
'project.1.comment', 'project.1.comment',
'project.1.number',
'project.1.invoice_text',
'project.1.order_date', 'project.1.order_date',
'project.1.order_number', 'project.1.order_number',
'project.1.meta.foo-project', 'project.1.meta.foo-project',

View File

@@ -152,6 +152,8 @@ class DebugRendererTest extends TestCase
'activity.id', 'activity.id',
'activity.name', 'activity.name',
'activity.comment', 'activity.comment',
'activity.number',
'activity.invoice_text',
'activity.meta.foo-activity', 'activity.meta.foo-activity',
'activity.budget_open', 'activity.budget_open',
'activity.budget_open_plain', 'activity.budget_open_plain',
@@ -184,6 +186,8 @@ class DebugRendererTest extends TestCase
'activity.1.id', 'activity.1.id',
'activity.1.name', 'activity.1.name',
'activity.1.comment', 'activity.1.comment',
'activity.1.number',
'activity.1.invoice_text',
'activity.1.meta.foo-activity', 'activity.1.meta.foo-activity',
]); ]);
} }
@@ -201,6 +205,8 @@ class DebugRendererTest extends TestCase
'project.id', 'project.id',
'project.name', 'project.name',
'project.comment', 'project.comment',
'project.number',
'project.invoice_text',
'project.order_date', 'project.order_date',
'project.order_number', 'project.order_number',
'project.meta.foo-project', 'project.meta.foo-project',
@@ -223,6 +229,8 @@ class DebugRendererTest extends TestCase
'project.1.id', 'project.1.id',
'project.1.name', 'project.1.name',
'project.1.comment', 'project.1.comment',
'project.1.number',
'project.1.invoice_text',
'project.1.order_date', 'project.1.order_date',
'project.1.order_number', 'project.1.order_number',
'project.1.meta.foo-project', 'project.1.meta.foo-project',
@@ -276,6 +284,7 @@ class DebugRendererTest extends TestCase
'entry.end_time', 'entry.end_time',
'entry.end_timestamp', 'entry.end_timestamp',
'entry.date', 'entry.date',
'entry.date_process',
'entry.week', 'entry.week',
'entry.weekyear', 'entry.weekyear',
'entry.user_id', 'entry.user_id',

View File

@@ -116,9 +116,9 @@ class PdfRendererTest extends KernelTestCase
$dirs = [ $dirs = [
__DIR__ . '/../../../templates/invoice/renderer/', __DIR__ . '/../../../templates/invoice/renderer/',
__DIR__ . '/../../../var/invoices/', //__DIR__ . '/../../../var/invoices/',
__DIR__ . '/../../../var/invoices_customer/', //__DIR__ . '/../../../var/invoices_customer/',
__DIR__ . '/../../../var/invoices_old/', //__DIR__ . '/../../../var/invoices_old/',
]; ];
$files = []; $files = [];

View File

@@ -200,8 +200,8 @@ class ServiceInvoiceTest extends TestCase
self::assertNotNull($query->getBegin()); self::assertNotNull($query->getBegin());
self::assertNotNull($query->getEnd()); self::assertNotNull($query->getEnd());
self::assertEquals('2010-01-27T00:00:00+0100', $query->getBegin()->format(DATE_ISO8601)); self::assertEquals('2010-01-27T00:00:00+01:00', $query->getBegin()->format(DATE_ATOM));
self::assertEquals('2020-11-27T23:59:59+0100', $query->getEnd()->format(DATE_ISO8601)); self::assertEquals('2020-11-27T23:59:59+01:00', $query->getEnd()->format(DATE_ATOM));
} }
public function testCreateModelsIncludesModelsWithNegativeTotal(): void public function testCreateModelsIncludesModelsWithNegativeTotal(): void

View File

@@ -271,7 +271,7 @@ class LocaleFormatExtensionsTest extends TestCase
$user->setTimezone('Asia/Dubai'); $user->setTimezone('Asia/Dubai');
$date = $sut->createDate('2019-08-27 16:30:45', $user); $date = $sut->createDate('2019-08-27 16:30:45', $user);
$this->assertEquals('2019-08-27T16:30:45+0400', $date->format(DATE_ISO8601)); $this->assertEquals('2019-08-27T16:30:45+04:00', $date->format(DATE_ATOM));
$this->assertEquals('Asia/Dubai', $date->getTimezone()->getName()); $this->assertEquals('Asia/Dubai', $date->getTimezone()->getName());
$date = $sut->createDate('2019-08-27 16:30:45', null); $date = $sut->createDate('2019-08-27 16:30:45', null);

View File

@@ -9,11 +9,8 @@
namespace App\Tests\Widget\Type; namespace App\Tests\Widget\Type;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\User; use App\Entity\User;
use App\Model\Statistic\Day; use App\Repository\TimesheetRepository;
use App\Widget\DataProvider\DailyWorkingTimeChartProvider;
use App\Widget\Type\DailyWorkingTimeChart; use App\Widget\Type\DailyWorkingTimeChart;
use App\Widget\WidgetInterface; use App\Widget\WidgetInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -27,7 +24,7 @@ class DailyWorkingTimeChartTest extends TestCase
{ {
public function createSut(): DailyWorkingTimeChart public function createSut(): DailyWorkingTimeChart
{ {
$repository = $this->createMock(DailyWorkingTimeChartProvider::class); $repository = $this->createMock(TimesheetRepository::class);
$sut = new DailyWorkingTimeChart($repository); $sut = new DailyWorkingTimeChart($repository);
$sut->setUser(new User()); $sut->setUser(new User());
@@ -65,54 +62,4 @@ class DailyWorkingTimeChartTest extends TestCase
self::assertStringStartsWith('DailyWorkingTimeChart_', $options['id']); self::assertStringStartsWith('DailyWorkingTimeChart_', $options['id']);
self::assertEquals('xxx', $options['type']); self::assertEquals('xxx', $options['type']);
} }
public function testGetData(): void
{
$activity = $this->createMock(Activity::class);
$activity->method('getId')->willReturn(42);
$project = $this->createMock(Project::class);
$project->method('getId')->willReturn(4711);
$repository = $this->getMockBuilder(DailyWorkingTimeChartProvider::class)->disableOriginalConstructor()->onlyMethods(['getDailyData'])->getMock();
$repository->expects($this->once())->method('getDailyData')->willReturnCallback(function ($begin, $end, $user) use ($activity, $project) {
return [
[
'year' => $begin->format('Y'),
'month' => $begin->format('n'),
'day' => $begin->format('j'),
'rate' => 13.75,
'duration' => 1234,
'billable' => 1234,
'details' => [
[
'activity' => $activity,
'project' => $project,
'billable' => 1234,
]
]
]
];
});
$sut = new DailyWorkingTimeChart($repository);
$sut->setUser(new User());
$data = $sut->getData($sut->getOptions());
self::assertCount(2, $data);
self::assertArrayHasKey('activities', $data);
self::assertArrayHasKey('data', $data);
self::assertCount(1, $data['activities']);
self::assertArrayHasKey('4711_42', $data['activities']);
self::assertCount(2, $data['activities']['4711_42']);
self::assertArrayHasKey('activity', $data['activities']['4711_42']);
self::assertArrayHasKey('project', $data['activities']['4711_42']);
self::assertSame($activity, $data['activities']['4711_42']['activity']);
self::assertSame($project, $data['activities']['4711_42']['project']);
self::assertCount(7, $data['data']);
foreach ($data['data'] as $statObj) {
self::assertInstanceOf(Day::class, $statObj);
}
}
} }

View File

@@ -5,6 +5,7 @@ includes:
- %rootDir%/../phpstan-doctrine/rules.neon - %rootDir%/../phpstan-doctrine/rules.neon
- %rootDir%/../phpstan-phpunit/extension.neon - %rootDir%/../phpstan-phpunit/extension.neon
- %rootDir%/../phpstan-phpunit/rules.neon - %rootDir%/../phpstan-phpunit/rules.neon
- %rootDir%/../phpstan-deprecation-rules/rules.neon
parameters: parameters:
level: 9 level: 9
@@ -4132,16 +4133,6 @@ parameters:
count: 1 count: 1
path: Widget/Type/AmountYearTest.php path: Widget/Type/AmountYearTest.php
-
message: "#^Parameter \\#2 \\$array of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertArrayHasKey\\(\\) expects array\\|ArrayAccess, mixed given\\.$#"
count: 1
path: Widget/Type/DailyWorkingTimeChartTest.php
-
message: "#^Parameter \\#2 \\$haystack of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertCount\\(\\) expects Countable\\|iterable, mixed given\\.$#"
count: 1
path: Widget/Type/DailyWorkingTimeChartTest.php
- -
message: "#^Method App\\\\Tests\\\\Widget\\\\Type\\\\DurationYearTest\\:\\:getDefaultOptions\\(\\) return type has no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Tests\\\\Widget\\\\Type\\\\DurationYearTest\\:\\:getDefaultOptions\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1 count: 1

View File

@@ -1544,10 +1544,18 @@
<source>Deactivated</source> <source>Deactivated</source>
<target>Deaktiviert</target> <target>Deaktiviert</target>
</trans-unit> </trans-unit>
<trans-unit id="WwesQPU" resname="deactivate">
<source>Deactivate</source>
<target>Deaktivieren</target>
</trans-unit>
<trans-unit id="3WHV.yf" resname="activated"> <trans-unit id="3WHV.yf" resname="activated">
<source>Activated</source> <source>Activated</source>
<target>Aktiviert</target> <target>Aktiviert</target>
</trans-unit> </trans-unit>
<trans-unit id="IAx_YiA" resname="activate">
<source>Activate</source>
<target>Aktivieren</target>
</trans-unit>
<trans-unit id="3Clo55j" resname="about.title"> <trans-unit id="3Clo55j" resname="about.title">
<source>about.title</source> <source>about.title</source>
<target>Über Kimai</target> <target>Über Kimai</target>

View File

@@ -1544,10 +1544,18 @@
<source>Deactivated</source> <source>Deactivated</source>
<target>Deactivated</target> <target>Deactivated</target>
</trans-unit> </trans-unit>
<trans-unit id="WwesQPU" resname="deactivate">
<source>Deactivate</source>
<target>Deactivate</target>
</trans-unit>
<trans-unit id="3WHV.yf" resname="activated"> <trans-unit id="3WHV.yf" resname="activated">
<source>Activated</source> <source>Activated</source>
<target>Activated</target> <target>Activated</target>
</trans-unit> </trans-unit>
<trans-unit id="IAx_YiA" resname="activate">
<source>Activate</source>
<target>Activate</target>
</trans-unit>
<trans-unit id="3Clo55j" resname="about.title"> <trans-unit id="3Clo55j" resname="about.title">
<source>about.title</source> <source>about.title</source>
<target>About Kimai</target> <target>About Kimai</target>