diff --git a/config/serializer/App/Entity.Tag.yml b/config/serializer/App/Entity.Tag.yml new file mode 100644 index 00000000..3686116f --- /dev/null +++ b/config/serializer/App/Entity.Tag.yml @@ -0,0 +1,12 @@ +App\Entity\Tag: + exclusion_policy: All + custom_accessor_order: [id, name, color] + properties: + id: + include: true + name: + include: true + color: + include: true + timesheets: + exclude: true diff --git a/config/validator/validation.yaml b/config/validator/validation.yaml index f0ed76fb..496e246a 100644 --- a/config/validator/validation.yaml +++ b/config/validator/validation.yaml @@ -11,3 +11,6 @@ App\Entity\User: plainPassword: - NotBlank: { groups: [Registration, PasswordUpdate] } - Length: { min: 8, max: 60, groups: [Registration, PasswordUpdate] } + plainApiToken: + - NotBlank: { groups: [ApiTokenUpdate] } + - Length: { min: 8, max: 60, groups: [ApiTokenUpdate] } diff --git a/src/API/ConfigurationController.php b/src/API/ConfigurationController.php index b8df98fd..4fd40dd0 100644 --- a/src/API/ConfigurationController.php +++ b/src/API/ConfigurationController.php @@ -94,7 +94,7 @@ final class ConfigurationController extends BaseApiController } /** - * Returns the instance specific timesheet configuration + * Returns the timesheet configuration * * @SWG\Response( * response=200, @@ -116,6 +116,7 @@ final class ConfigurationController extends BaseApiController ->setActiveEntriesHardLimit($this->timesheetConfiguration->getActiveEntriesHardLimit()) ->setActiveEntriesSoftLimit($this->timesheetConfiguration->getActiveEntriesSoftLimit()) ->setIsAllowFutureTimes($this->timesheetConfiguration->isAllowFutureTimes()) + ->setIsAllowOverlapping($this->timesheetConfiguration->isAllowOverlappingRecords()) ; $view = new View($model, 200); diff --git a/src/API/Model/TimesheetConfig.php b/src/API/Model/TimesheetConfig.php index 3560c728..0db48377 100644 --- a/src/API/Model/TimesheetConfig.php +++ b/src/API/Model/TimesheetConfig.php @@ -20,21 +20,35 @@ final class TimesheetConfig */ private $trackingMode = 'default'; /** + * Default begin datetime in PHP format + * * @var string */ private $defaultBeginTime = 'now'; /** + * How many running timesheets a user is allowed to have at the same time + * * @var int */ private $activeEntriesHardLimit = 1; /** + * How many running timesheets a user is allowed before a warning is shown + * * @var int */ private $activeEntriesSoftLimit = 1; /** + * Whether entries for future times are allowed + * * @var bool */ private $isAllowFutureTimes = true; + /** + * Whether overlapping entries are allowed + * + * @var bool + */ + private $isAllowOverlapping = true; /** * @return string @@ -98,4 +112,16 @@ final class TimesheetConfig return $this; } + + public function isAllowOverlapping(): bool + { + return $this->isAllowOverlapping; + } + + public function setIsAllowOverlapping(bool $isAllowOverlapping): TimesheetConfig + { + $this->isAllowOverlapping = $isAllowOverlapping; + + return $this; + } } diff --git a/src/API/Model/Version.php b/src/API/Model/Version.php index 6c43d512..fa7fe701 100644 --- a/src/API/Model/Version.php +++ b/src/API/Model/Version.php @@ -16,22 +16,32 @@ use App\Constants; class Version { /** + * Kimai Version, eg. "1.9" + * * @var string */ protected $version = Constants::VERSION; /** + * Candidate: either "prod" or "dev" + * * @var string */ protected $candidate = Constants::STATUS; /** + * Full version including status, eg: "1.9-prod" + * * @var string */ protected $semver = Constants::VERSION . '-' . Constants::STATUS; /** + * The version name + * * @var string */ protected $name = Constants::NAME; /** + * A full copyright notice + * * @var string */ protected $copyright = Constants::SOFTWARE . ' - ' . Constants::VERSION . ' ' . Constants::STATUS . ' (' . Constants::NAME . ') by Kevin Papst and contributors.'; diff --git a/src/API/UserController.php b/src/API/UserController.php index 73da67c9..5ec1f8af 100644 --- a/src/API/UserController.php +++ b/src/API/UserController.php @@ -93,6 +93,7 @@ final class UserController extends BaseApiController public function cgetAction(ParamFetcherInterface $paramFetcher): Response { $query = new UserQuery(); + $query->setCurrentUser($this->getUser()); if (null !== ($visible = $paramFetcher->get('visible'))) { $query->setVisibility($visible); diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index c2c696d8..bcfa1582 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -376,7 +376,6 @@ class ProfileController extends AbstractController UserApiTokenType::class, $user, [ - 'validation_groups' => ['apiTokenUpdate'], 'action' => $this->generateUrl('user_profile_api_token', ['username' => $user->getUsername()]), 'method' => 'POST' ] diff --git a/src/Controller/TeamController.php b/src/Controller/TeamController.php index 457b4eda..41ea00fc 100644 --- a/src/Controller/TeamController.php +++ b/src/Controller/TeamController.php @@ -83,6 +83,42 @@ final class TeamController extends AbstractController return $this->renderEditScreen(new Team(), $request); } + /** + * @Route(path="/{id}/duplicate", name="team_duplicate", methods={"GET", "POST"}) + * @Security("is_granted('edit', team) and is_granted('create_team')") + */ + public function duplicateTeam(Team $team, Request $request) + { + $newTeam = new Team(); + $newTeam->setName($team->getName() . ' [COPY]'); + $newTeam->setTeamLead($team->getTeamLead()); + + foreach ($team->getUsers() as $user) { + $newTeam->addUser($user); + } + foreach ($team->getCustomers() as $customer) { + $newTeam->addCustomer($customer); + } + foreach ($team->getProjects() as $project) { + $newTeam->addProject($project); + } + + try { + // make sure that the teamlead is always part of the team, otherwise permission checks + // and filtering might not work as expected! + $team->addUser($team->getTeamLead()); + + $this->repository->saveTeam($newTeam); + $this->flashSuccess('action.update.success'); + + return $this->redirectToRoute('admin_team_edit', ['id' => $newTeam->getId()]); + } catch (\Exception $ex) { + $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); + } + + return $this->redirectToRoute('admin_team'); + } + /** * @Route(path="/{id}/edit", name="admin_team_edit", methods={"GET", "POST"}) * @Security("is_granted('edit', team)") diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index 309c48b1..7d1465d0 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -70,6 +70,7 @@ final class UserController extends AbstractController public function indexAction($page, Request $request): Response { $query = new UserQuery(); + $query->setCurrentUser($this->getUser()); $query->setPage($page); $form = $this->getToolbarForm($query); diff --git a/src/Form/API/TimesheetApiEditForm.php b/src/Form/API/TimesheetApiEditForm.php index eff1e67b..8abe4661 100644 --- a/src/Form/API/TimesheetApiEditForm.php +++ b/src/Form/API/TimesheetApiEditForm.php @@ -50,6 +50,11 @@ class TimesheetApiEditForm extends TimesheetEditForm $resolver->setDefaults([ 'csrf_protection' => false, 'allow_duration' => false, + // overwritten and changed to default "true", + // because the docs are cached without these fields otherwise + 'include_user' => true, + 'include_exported' => true, + 'include_rate' => true, ]); } } diff --git a/src/Form/UserApiTokenType.php b/src/Form/UserApiTokenType.php index 9d360d86..7f4424e7 100644 --- a/src/Form/UserApiTokenType.php +++ b/src/Form/UserApiTokenType.php @@ -41,6 +41,7 @@ class UserApiTokenType extends AbstractType public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ + 'validation_groups' => ['ApiTokenUpdate'], 'data_class' => User::class, 'csrf_protection' => true, 'csrf_field_name' => '_token', diff --git a/src/Repository/Query/VisibilityTrait.php b/src/Repository/Query/VisibilityTrait.php index 6215c3e4..122b4d07 100644 --- a/src/Repository/Query/VisibilityTrait.php +++ b/src/Repository/Query/VisibilityTrait.php @@ -30,4 +30,19 @@ trait VisibilityTrait return $this; } + + public function isShowHidden(): bool + { + return $this->visibility === VisibilityInterface::SHOW_HIDDEN; + } + + public function isShowVisible(): bool + { + return $this->visibility === VisibilityInterface::SHOW_VISIBLE; + } + + public function isShowBoth(): bool + { + return $this->visibility === VisibilityInterface::SHOW_BOTH; + } } diff --git a/templates/activity/actions.html.twig b/templates/activity/actions.html.twig index 73716869..1061fe3d 100644 --- a/templates/activity/actions.html.twig +++ b/templates/activity/actions.html.twig @@ -32,7 +32,7 @@ {% set actions = actions|merge({'divider': null}) %} {% endif %} {% if is_granted('view_other_timesheet') %} - {% set actions = actions|merge({'timesheet': path('admin_timesheet', {'customer': activity.project ? activity.project.customer.id : null, 'project': activity.project ? activity.project.id : null, 'activity': activity.id})}) %} + {% set actions = actions|merge({'timesheet': path('admin_timesheet', {'customers[]': activity.project ? activity.project.customer.id : null, 'projects[]': activity.project ? activity.project.id : null, 'activities[]': activity.id})}) %} {% endif %} {% if is_granted('create_other_timesheet') %} {% set actions = actions|merge({'create-timesheet': {'url': path('admin_timesheet_create', {'project': activity.project ? activity.project.id : null, 'activity': activity.id}), 'class': 'modal-ajax-form'}}) %} diff --git a/templates/customer/actions.html.twig b/templates/customer/actions.html.twig index a876755b..edba6500 100644 --- a/templates/customer/actions.html.twig +++ b/templates/customer/actions.html.twig @@ -39,13 +39,13 @@ {% set actions = actions|merge({'divider': null}) %} {% endif %} {% if is_granted('view_project') or is_granted('view_teamlead_project') or is_granted('view_team_project') %} - {% set actions = actions|merge({'project': path('admin_project', {'customer': customer.id})}) %} + {% set actions = actions|merge({'project': path('admin_project', {'customers[]': customer.id})}) %} {% endif %} {% if is_granted('view_activity') %} - {% set actions = actions|merge({'activity': path('admin_activity', {'customer': customer.id})}) %} + {% set actions = actions|merge({'activity': path('admin_activity', {'customers[]': customer.id})}) %} {% endif %} {% if is_granted('view_other_timesheet') %} - {% set actions = actions|merge({'timesheet': path('admin_timesheet', {'customer': customer.id})}) %} + {% set actions = actions|merge({'timesheet': path('admin_timesheet', {'customers[]': customer.id})}) %} {% endif %} {% if customer.visible and is_granted('create_project') %} {% set actions = actions|merge({'create-project': path('admin_project_create_with_customer', {'customer': customer.id})}) %} diff --git a/templates/project/actions.html.twig b/templates/project/actions.html.twig index 883ea08d..367ec6f4 100644 --- a/templates/project/actions.html.twig +++ b/templates/project/actions.html.twig @@ -40,10 +40,10 @@ {% set actions = actions|merge({'divider': null}) %} {% endif %} {% if is_granted('view_activity') %} - {% set actions = actions|merge({'activity': path('admin_activity', {'customer': project.customer.id, 'project': project.id})}) %} + {% set actions = actions|merge({'activity': path('admin_activity', {'customers[]': project.customer.id, 'projects[]': project.id})}) %} {% endif %} {% if is_granted('view_other_timesheet') %} - {% set actions = actions|merge({'timesheet': path('admin_timesheet', {'customer': project.customer.id, 'project': project.id})}) %} + {% set actions = actions|merge({'timesheet': path('admin_timesheet', {'customers[]': project.customer.id, 'projects[]': project.id})}) %} {% endif %} {% if is_granted('create_activity') and project.visible and project.customer.visible %} {% set actions = actions|merge({'create-activity': path('admin_activity_create_with_project', {'project': project.id})}) %} diff --git a/templates/team/actions.html.twig b/templates/team/actions.html.twig index 8dfeb963..c8fe2ccf 100644 --- a/templates/team/actions.html.twig +++ b/templates/team/actions.html.twig @@ -21,6 +21,9 @@ {% if is_granted('edit', team) %} {% set class = '' %} {% set actions = actions|merge({'edit': {'url': path('admin_team_edit', {'id': team.id}), 'class': class}}) %} + {% if is_granted('create_team') %} + {% set actions = actions|merge({'copy': {'url': path('team_duplicate', {'id': team.id})}}) %} + {% endif %} {% endif %} {% endif %} diff --git a/tests/API/ConfigurationControllerTest.php b/tests/API/ConfigurationControllerTest.php index 55a05171..be57f63e 100644 --- a/tests/API/ConfigurationControllerTest.php +++ b/tests/API/ConfigurationControllerTest.php @@ -56,13 +56,13 @@ class ConfigurationControllerTest extends APIControllerBaseTest $this->assertIsArray($result); $this->assertNotEmpty($result); - $this->assertEquals(5, \count($result)); + $this->assertEquals(6, \count($result)); $this->assertTimesheetStructure($result); } protected function assertTimesheetStructure(array $result) { - $expectedKeys = ['activeEntriesHardLimit', 'activeEntriesSoftLimit', 'defaultBeginTime', 'isAllowFutureTimes', 'trackingMode']; + $expectedKeys = ['activeEntriesHardLimit', 'activeEntriesSoftLimit', 'defaultBeginTime', 'isAllowFutureTimes', 'isAllowOverlapping', 'trackingMode']; $actual = array_keys($result); sort($actual); sort($expectedKeys); diff --git a/tests/API/Model/TimesheetConfigTest.php b/tests/API/Model/TimesheetConfigTest.php index a238d469..9ae0e162 100644 --- a/tests/API/Model/TimesheetConfigTest.php +++ b/tests/API/Model/TimesheetConfigTest.php @@ -21,6 +21,7 @@ class TimesheetConfigTest extends TestCase { $sut = new TimesheetConfig(); $this->assertTrue($sut->isAllowFutureTimes()); + $this->assertTrue($sut->isAllowOverlapping()); $this->assertEquals('now', $sut->getDefaultBeginTime()); $this->assertEquals('default', $sut->getTrackingMode()); $this->assertEquals(1, $sut->getActiveEntriesSoftLimit()); @@ -32,12 +33,14 @@ class TimesheetConfigTest extends TestCase $sut = new TimesheetConfig(); $this->assertInstanceOf(TimesheetConfig::class, $sut->setIsAllowFutureTimes(false)); + $this->assertInstanceOf(TimesheetConfig::class, $sut->setIsAllowOverlapping(false)); $this->assertInstanceOf(TimesheetConfig::class, $sut->setDefaultBeginTime('08:00')); $this->assertInstanceOf(TimesheetConfig::class, $sut->setTrackingMode('punch')); $this->assertInstanceOf(TimesheetConfig::class, $sut->setActiveEntriesSoftLimit(2)); $this->assertInstanceOf(TimesheetConfig::class, $sut->setActiveEntriesHardLimit(3)); $this->assertFalse($sut->isAllowFutureTimes()); + $this->assertFalse($sut->isAllowOverlapping()); $this->assertEquals('08:00', $sut->getDefaultBeginTime()); $this->assertEquals('punch', $sut->getTrackingMode()); $this->assertEquals(2, $sut->getActiveEntriesSoftLimit()); diff --git a/tests/API/TagControllerTest.php b/tests/API/TagControllerTest.php index 53375503..1c78500e 100644 --- a/tests/API/TagControllerTest.php +++ b/tests/API/TagControllerTest.php @@ -132,7 +132,7 @@ class TagControllerTest extends APIControllerBaseTest protected function assertStructure(array $result, $full = true) { $expectedKeys = [ - 'id', 'name', 'color', 'timesheets' + 'id', 'name', 'color' ]; if ($full) { diff --git a/tests/Controller/ProfileControllerTest.php b/tests/Controller/ProfileControllerTest.php index 369cef0d..f1b1d3ea 100644 --- a/tests/Controller/ProfileControllerTest.php +++ b/tests/Controller/ProfileControllerTest.php @@ -243,6 +243,24 @@ class ProfileControllerTest extends ControllerBaseTest $this->assertTrue($passwordEncoder->getEncoder($user)->isPasswordValid($user->getPassword(), 'test1234', $user->getSalt())); } + public function testPasswordActionFailsIfPasswordLengthToShort() + { + $this->assertFormHasValidationError( + User::ROLE_USER, + '/profile/' . UserFixtures::USERNAME_USER . '/password', + 'form[name=user_password]', + [ + 'user_password' => [ + 'plainPassword' => [ + 'first' => 'abcdef1', + 'second' => 'abcdef1', + ] + ] + ], + ['#user_password_plainPassword_first'] + ); + } + public function testApiTokenAction() { $client = $this->getClientForAuthenticatedUser(User::ROLE_USER); @@ -255,15 +273,15 @@ class ProfileControllerTest extends ControllerBaseTest $passwordEncoder = static::$kernel->getContainer()->get('test.PasswordEncoder'); $this->assertTrue($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), UserFixtures::DEFAULT_API_TOKEN, $user->getSalt())); - $this->assertFalse($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), 'test123', $user->getSalt())); + $this->assertFalse($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), 'test1234', $user->getSalt())); $this->assertEquals(UserFixtures::USERNAME_USER, $user->getUsername()); $form = $client->getCrawler()->filter('form[name=user_api_token]')->form(); $client->submit($form, [ 'user_api_token' => [ 'plainApiToken' => [ - 'first' => 'test123', - 'second' => 'test123', + 'first' => 'test1234', + 'second' => 'test1234', ] ] ]); @@ -278,7 +296,25 @@ class ProfileControllerTest extends ControllerBaseTest $user = $this->getUserByRole(User::ROLE_USER); $this->assertFalse($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), UserFixtures::DEFAULT_API_TOKEN, $user->getSalt())); - $this->assertTrue($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), 'test123', $user->getSalt())); + $this->assertTrue($passwordEncoder->getEncoder($user)->isPasswordValid($user->getApiToken(), 'test1234', $user->getSalt())); + } + + public function testApiTokenActionFailsIfPasswordLengthToShort() + { + $this->assertFormHasValidationError( + User::ROLE_USER, + '/profile/' . UserFixtures::USERNAME_USER . '/api-token', + 'form[name=user_api_token]', + [ + 'user_api_token' => [ + 'plainApiToken' => [ + 'first' => 'abcdef1', + 'second' => 'abcdef1', + ] + ] + ], + ['#user_api_token_plainApiToken_first'] + ); } public function testRolesActionIsSecured() diff --git a/tests/Controller/TeamControllerTest.php b/tests/Controller/TeamControllerTest.php index f6a74e70..e75d11ad 100644 --- a/tests/Controller/TeamControllerTest.php +++ b/tests/Controller/TeamControllerTest.php @@ -205,4 +205,15 @@ class TeamControllerTest extends ControllerBaseTest $team = $em->getRepository(Team::class)->find(1); self::assertEquals(1, \count($team->getProjects())); } + + public function testDuplicateAction() + { + $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); + $this->request($client, '/admin/teams/1/duplicate'); + $this->assertIsRedirect($client, $this->createUrl('/admin/teams/2/edit')); + $client->followRedirect(); + $node = $client->getCrawler()->filter('#team_edit_form_name'); + self::assertEquals(1, $node->count()); + self::assertEquals('Test team [COPY]', $node->attr('value')); + } } diff --git a/tests/Repository/Query/VisibilityQueryTest.php b/tests/Repository/Query/VisibilityQueryTest.php index cf2de434..9b6133a8 100644 --- a/tests/Repository/Query/VisibilityQueryTest.php +++ b/tests/Repository/Query/VisibilityQueryTest.php @@ -25,18 +25,27 @@ class VisibilityQueryTest extends TestCase $sut = new VisibilityQuery(); $this->assertEquals(VisibilityQuery::SHOW_VISIBLE, $sut->getVisibility()); + self::assertTrue($sut->isShowVisible()); + self::assertFalse($sut->isShowHidden()); + self::assertFalse($sut->isShowBoth()); $sut->setVisibility('foo-bar'); $this->assertEquals(VisibilityQuery::SHOW_VISIBLE, $sut->getVisibility()); $sut->setVisibility('2'); $this->assertEquals(VisibilityQuery::SHOW_HIDDEN, $sut->getVisibility()); + self::assertFalse($sut->isShowVisible()); + self::assertTrue($sut->isShowHidden()); + self::assertFalse($sut->isShowBoth()); $sut->setVisibility('0'); // keep the value that was previously set $this->assertEquals(VisibilityQuery::SHOW_HIDDEN, $sut->getVisibility()); $sut->setVisibility(VisibilityQuery::SHOW_BOTH); $this->assertEquals(VisibilityQuery::SHOW_BOTH, $sut->getVisibility()); + self::assertFalse($sut->isShowVisible()); + self::assertFalse($sut->isShowHidden()); + self::assertTrue($sut->isShowBoth()); $sut->setVisibility(VisibilityQuery::SHOW_HIDDEN); $this->assertEquals(VisibilityQuery::SHOW_HIDDEN, $sut->getVisibility()); @@ -50,18 +59,27 @@ class VisibilityQueryTest extends TestCase $sut = new VisibilityTraitImpl(); $this->assertEquals(VisibilityInterface::SHOW_VISIBLE, $sut->getVisibility()); + self::assertTrue($sut->isShowVisible()); + self::assertFalse($sut->isShowHidden()); + self::assertFalse($sut->isShowBoth()); $sut->setVisibility('foo-bar'); $this->assertEquals(VisibilityInterface::SHOW_VISIBLE, $sut->getVisibility()); $sut->setVisibility('2'); $this->assertEquals(VisibilityInterface::SHOW_HIDDEN, $sut->getVisibility()); + self::assertFalse($sut->isShowVisible()); + self::assertTrue($sut->isShowHidden()); + self::assertFalse($sut->isShowBoth()); $sut->setVisibility('0'); // keep the value that was previously set $this->assertEquals(VisibilityInterface::SHOW_HIDDEN, $sut->getVisibility()); $sut->setVisibility(VisibilityInterface::SHOW_BOTH); $this->assertEquals(VisibilityInterface::SHOW_BOTH, $sut->getVisibility()); + self::assertFalse($sut->isShowVisible()); + self::assertFalse($sut->isShowHidden()); + self::assertTrue($sut->isShowBoth()); $sut->setVisibility(VisibilityInterface::SHOW_HIDDEN); $this->assertEquals(VisibilityInterface::SHOW_HIDDEN, $sut->getVisibility());