diff --git a/composer.lock b/composer.lock index a533ed66..db5fcebb 100644 --- a/composer.lock +++ b/composer.lock @@ -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.0", "source": { "type": "git", "url": "https://github.com/nelmio/NelmioApiDocBundle.git", - "reference": "eb0453607560e63bbbc6a746978933f77a787575" + "reference": "2a79732f8d10a5a0faf3934d906ac9e4c720aa2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/eb0453607560e63bbbc6a746978933f77a787575", - "reference": "eb0453607560e63bbbc6a746978933f77a787575", + "url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/2a79732f8d10a5a0faf3934d906ac9e4c720aa2f", + "reference": "2a79732f8d10a5a0faf3934d906ac9e4c720aa2f", "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.0" }, "funding": [ { @@ -3582,7 +3582,7 @@ "type": "github" } ], - "time": "2025-10-20T08:34:48+00:00" + "time": "2025-11-10T09:23:31+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", diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 06310406..b798664e 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -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 diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 009a6ef9..03b6cc32 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -42,6 +42,7 @@ security: - App\Saml\SamlAuthenticator remember_me: + name: KIMAI_REMEMBER secret: '%kernel.secret%' lifetime: 604800 path: / diff --git a/phpstan.neon b/phpstan.neon index 0b6000d6..f8238a78 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -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 diff --git a/src/API/ActivityController.php b/src/API/ActivityController.php index 21832968..8114e7bc 100644 --- a/src/API/ActivityController.php +++ b/src/API/ActivityController.php @@ -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; @@ -46,7 +43,6 @@ final class ActivityController extends BaseApiController 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 +59,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 +96,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); @@ -149,9 +131,7 @@ final class ActivityController extends BaseApiController } $activity = new Activity(); - - $event = new ActivityMetaDefinitionEvent($activity); - $this->dispatcher->dispatch($event); + $this->activityService->loadMetaFields($activity); $form = $this->createForm(ActivityApiEditForm::class, $activity, [ 'include_budget' => $this->isGranted('budget', $activity), @@ -185,8 +165,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), @@ -241,8 +220,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'); diff --git a/src/API/BaseApiController.php b/src/API/BaseApiController.php index ce475d54..deed0d71 100644 --- a/src/API/BaseApiController.php +++ b/src/API/BaseApiController.php @@ -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); } diff --git a/src/API/ConfigurationController.php b/src/API/ConfigurationController.php index 7cdeb65f..57205c64 100644 --- a/src/API/ConfigurationController.php +++ b/src/API/ConfigurationController.php @@ -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); } } diff --git a/src/API/CustomerController.php b/src/API/CustomerController.php index b6ce3795..e7542df7 100644 --- a/src/API/CustomerController.php +++ b/src/API/CustomerController.php @@ -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; @@ -45,7 +43,6 @@ final class CustomerController extends BaseApiController 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 +56,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 +122,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), @@ -160,8 +154,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), @@ -216,8 +209,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'); diff --git a/src/API/ExportController.php b/src/API/ExportController.php index 3e6aaa6c..b50a99ce 100644 --- a/src/API/ExportController.php +++ b/src/API/ExportController.php @@ -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); } } diff --git a/src/API/ProjectController.php b/src/API/ProjectController.php index 9c036aa4..6d82a1b7 100644 --- a/src/API/ProjectController.php +++ b/src/API/ProjectController.php @@ -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; @@ -47,7 +45,6 @@ final class ProjectController extends BaseApiController 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 +64,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 */ @@ -212,8 +209,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(), @@ -270,8 +266,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'); diff --git a/src/API/TimesheetController.php b/src/API/TimesheetController.php index 135dd10a..7676a715 100644 --- a/src/API/TimesheetController.php +++ b/src/API/TimesheetController.php @@ -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 { diff --git a/src/API/UserController.php b/src/API/UserController.php index ab6a9083..2d39e0cb 100644 --- a/src/API/UserController.php +++ b/src/API/UserController.php @@ -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 { diff --git a/src/Activity/ActivityService.php b/src/Activity/ActivityService.php index f862d900..20238329 100644 --- a/src/Activity/ActivityService.php +++ b/src/Activity/ActivityService.php @@ -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); } /** diff --git a/src/Constants.php b/src/Constants.php index a90a5ea3..0649c4be 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -17,11 +17,11 @@ final class Constants /** * The current release version */ - public const VERSION = '2.41.0'; + public const VERSION = '2.42.0'; /** * The current release: major * 10000 + minor * 100 + patch */ - public const VERSION_ID = 24100; + public const VERSION_ID = 24200; /** * The software name */ diff --git a/src/Controller/ActivityController.php b/src/Controller/ActivityController.php index 130c5bdc..44596a48 100644 --- a/src/Controller/ActivityController.php +++ b/src/Controller/ActivityController.php @@ -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 */ - 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()]); diff --git a/src/Controller/CustomerController.php b/src/Controller/CustomerController.php index 0e469d9f..9195767d 100644 --- a/src/Controller/CustomerController.php +++ b/src/Controller/CustomerController.php @@ -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 - */ - 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'); diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index e84a9c45..fd486048 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -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) { diff --git a/src/Customer/CustomerService.php b/src/Customer/CustomerService.php index c59150e5..aedc0204 100644 --- a/src/Customer/CustomerService.php +++ b/src/Customer/CustomerService.php @@ -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); } /** diff --git a/src/DataFixtures/InvoiceFixtures.php b/src/DataFixtures/InvoiceFixtures.php index ed9468d4..87c05ef8 100644 --- a/src/DataFixtures/InvoiceFixtures.php +++ b/src/DataFixtures/InvoiceFixtures.php @@ -31,7 +31,7 @@ final class InvoiceFixtures extends Fixture { $faker = Factory::create('en_US'); /** @var non-empty-array $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 diff --git a/src/DataFixtures/TimesheetFixtures.php b/src/DataFixtures/TimesheetFixtures.php index 117b1c0d..b047674a 100644 --- a/src/DataFixtures/TimesheetFixtures.php +++ b/src/DataFixtures/TimesheetFixtures.php @@ -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); diff --git a/src/Event/AbstractActivityEvent.php b/src/Event/AbstractActivityEvent.php index 59daf4fd..72b2f589 100644 --- a/src/Event/AbstractActivityEvent.php +++ b/src/Event/AbstractActivityEvent.php @@ -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) { } diff --git a/src/Event/AbstractCustomerEvent.php b/src/Event/AbstractCustomerEvent.php index 1ca2aa49..557cd793 100644 --- a/src/Event/AbstractCustomerEvent.php +++ b/src/Event/AbstractCustomerEvent.php @@ -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) { } diff --git a/src/Event/AbstractProjectEvent.php b/src/Event/AbstractProjectEvent.php index d5547365..1de47050 100644 --- a/src/Event/AbstractProjectEvent.php +++ b/src/Event/AbstractProjectEvent.php @@ -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) { } diff --git a/src/Event/ActivityDeleteEvent.php b/src/Event/ActivityDeleteEvent.php index 695320a3..da9d62d8 100644 --- a/src/Event/ActivityDeleteEvent.php +++ b/src/Event/ActivityDeleteEvent.php @@ -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; + } } diff --git a/src/Event/ActivityMetaDefinitionEvent.php b/src/Event/ActivityMetaDefinitionEvent.php index 4dc0c2c4..1ffa2c11 100644 --- a/src/Event/ActivityMetaDefinitionEvent.php +++ b/src/Event/ActivityMetaDefinitionEvent.php @@ -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) { } diff --git a/src/Event/CustomerDeleteEvent.php b/src/Event/CustomerDeleteEvent.php index 44420135..502c2325 100644 --- a/src/Event/CustomerDeleteEvent.php +++ b/src/Event/CustomerDeleteEvent.php @@ -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; + } } diff --git a/src/Event/CustomerMetaDefinitionEvent.php b/src/Event/CustomerMetaDefinitionEvent.php index 34b1a7a5..985ff63c 100644 --- a/src/Event/CustomerMetaDefinitionEvent.php +++ b/src/Event/CustomerMetaDefinitionEvent.php @@ -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) { } diff --git a/src/Event/ProjectDeleteEvent.php b/src/Event/ProjectDeleteEvent.php index ac55ad88..325086cd 100644 --- a/src/Event/ProjectDeleteEvent.php +++ b/src/Event/ProjectDeleteEvent.php @@ -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; + } } diff --git a/src/Event/ProjectMetaDefinitionEvent.php b/src/Event/ProjectMetaDefinitionEvent.php index 4577d53f..13f4ec36 100644 --- a/src/Event/ProjectMetaDefinitionEvent.php +++ b/src/Event/ProjectMetaDefinitionEvent.php @@ -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) { } diff --git a/src/Mail/KimaiMailer.php b/src/Mail/KimaiMailer.php index f15049ba..8c244b14 100644 --- a/src/Mail/KimaiMailer.php +++ b/src/Mail/KimaiMailer.php @@ -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; diff --git a/src/Project/ProjectService.php b/src/Project/ProjectService.php index bd991f0b..4e0fe957 100644 --- a/src/Project/ProjectService.php +++ b/src/Project/ProjectService.php @@ -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); } /** diff --git a/src/Repository/ActivityRepository.php b/src/Repository/ActivityRepository.php index 68d4731f..21201d00 100644 --- a/src/Repository/ActivityRepository.php +++ b/src/Repository/ActivityRepository.php @@ -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; } diff --git a/src/Repository/CustomerRepository.php b/src/Repository/CustomerRepository.php index ee9e5f6a..836c1bda 100644 --- a/src/Repository/CustomerRepository.php +++ b/src/Repository/CustomerRepository.php @@ -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; } diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index 00e221ec..0741c805 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -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; } diff --git a/src/Repository/RoleRepository.php b/src/Repository/RoleRepository.php index e5f16c87..5a066cb4 100644 --- a/src/Repository/RoleRepository.php +++ b/src/Repository/RoleRepository.php @@ -11,7 +11,6 @@ namespace App\Repository; use App\Entity\Role; use Doctrine\ORM\EntityRepository; -use Doctrine\ORM\Exception\ORMException; /** * @extends EntityRepository @@ -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; } diff --git a/src/User/LoginManager.php b/src/User/LoginManager.php index 128daba5..b5b1fa4f 100644 --- a/src/User/LoginManager.php +++ b/src/User/LoginManager.php @@ -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); diff --git a/templates/calendar/user.html.twig b/templates/calendar/user.html.twig index 80bf7a4b..5fc7e740 100644 --- a/templates/calendar/user.html.twig +++ b/templates/calendar/user.html.twig @@ -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') }}', }, diff --git a/tests/Event/ActivityDeleteEventTest.php b/tests/Event/ActivityDeleteEventTest.php new file mode 100644 index 00000000..1016e1e1 --- /dev/null +++ b/tests/Event/ActivityDeleteEventTest.php @@ -0,0 +1,36 @@ +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()); + } +} diff --git a/tests/Event/CustomerDeleteEventTest.php b/tests/Event/CustomerDeleteEventTest.php new file mode 100644 index 00000000..fbbb1dda --- /dev/null +++ b/tests/Event/CustomerDeleteEventTest.php @@ -0,0 +1,35 @@ +getCustomer()); + self::assertSame($replacement, $sut->getReplacementCustomer()); + } +} diff --git a/tests/Event/ProjectDeleteEventTest.php b/tests/Event/ProjectDeleteEventTest.php new file mode 100644 index 00000000..d60168b8 --- /dev/null +++ b/tests/Event/ProjectDeleteEventTest.php @@ -0,0 +1,37 @@ +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()); + } +} diff --git a/tests/Timesheet/UtilTest.php b/tests/Timesheet/UtilTest.php index 75e1252e..8c0a977e 100644 --- a/tests/Timesheet/UtilTest.php +++ b/tests/Timesheet/UtilTest.php @@ -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])); + } + } } diff --git a/tests/phpstan.neon b/tests/phpstan.neon index 16b2b0a6..3d999d88 100644 --- a/tests/phpstan.neon +++ b/tests/phpstan.neon @@ -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