Next major version 2 with PHP 8.1, Symfony 6, Tabler UI, 2FA ... (#2902)
This commit is contained in:
@@ -94,10 +94,7 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
return $parts[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName($this->getInstallerCommandName())
|
||||
@@ -106,12 +103,7 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int|null
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
@@ -132,7 +124,7 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
sprintf('Failed to install database for bundle %s. %s', $bundleName, $ex->getMessage())
|
||||
);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->hasAssets()) {
|
||||
@@ -143,7 +135,7 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
sprintf('Failed to install assets for bundle %s. %s', $bundleName, $ex->getMessage())
|
||||
);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +145,7 @@ abstract class AbstractBundleInstallerCommand extends Command
|
||||
sprintf('Congratulations! Plugin was successful installed: %s', $bundleName)
|
||||
);
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function installAssets(SymfonyStyle $io, OutputInterface $output)
|
||||
|
||||
@@ -26,17 +26,19 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
*/
|
||||
abstract class AbstractResetCommand extends Command
|
||||
{
|
||||
public function __construct(private string $kernelEnvironment)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:reset:' . $this->getEnvName())
|
||||
->setAliases(['kimai:reset-' . $this->getEnvName()])
|
||||
->setDescription('Resets the "' . $this->getEnvName() . '" environment')
|
||||
->setHelp(
|
||||
<<<EOT
|
||||
This command will drop and re-create the database and its schemas, load data and clear the cache.
|
||||
Use the <info>-n</info> switch to skip the question.
|
||||
EOT
|
||||
This command will drop and re-create the database and its schemas, load data and clear the cache.
|
||||
Use the <info>-n</info> switch to skip the question.
|
||||
EOT
|
||||
)
|
||||
->addOption('no-cache', null, InputOption::VALUE_NONE, 'Skip cache flushing')
|
||||
;
|
||||
@@ -44,16 +46,7 @@ EOT
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->getEnv() !== 'prod';
|
||||
}
|
||||
|
||||
private function getEnv(): string
|
||||
{
|
||||
/** @var Application $application */
|
||||
$application = $this->getApplication();
|
||||
$kernel = $application->getKernel();
|
||||
|
||||
return $kernel->getEnvironment();
|
||||
return $this->kernelEnvironment !== 'prod';
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
@@ -68,12 +61,12 @@ EOT
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to create database: ' . $ex->getMessage());
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->askConfirmation($input, $output, 'Do you want to drop and re-create the schema y/N ?')) {
|
||||
if (($result = $this->dropSchema($io, $output)) !== 0) {
|
||||
if (($result = $this->dropSchema($io, $output)) !== Command::SUCCESS) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -85,7 +78,7 @@ EOT
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to execute a migrations: ' . $ex->getMessage());
|
||||
|
||||
return 5;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +87,7 @@ EOT
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to import data: ' . $ex->getMessage());
|
||||
|
||||
return 6;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$input->getOption('no-cache')) {
|
||||
@@ -104,11 +97,11 @@ EOT
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to clear cache: ' . $ex->getMessage());
|
||||
|
||||
return 7;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function dropSchema(SymfonyStyle $io, OutputInterface $output): int
|
||||
@@ -119,7 +112,7 @@ EOT
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to drop database schema: ' . $ex->getMessage());
|
||||
|
||||
return 2;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -128,7 +121,7 @@ EOT
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to drop migration_versions table: ' . $ex->getMessage());
|
||||
|
||||
return 3;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -137,10 +130,10 @@ EOT
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to drop kimai2_sessions table: ' . $ex->getMessage());
|
||||
|
||||
return 4;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function askConfirmation(InputInterface $input, OutputInterface $output, string $question): bool
|
||||
@@ -156,7 +149,5 @@ EOT
|
||||
return $questionHelper->ask($input, $output, $question);
|
||||
}
|
||||
|
||||
abstract protected function getEnvName(): string;
|
||||
|
||||
abstract protected function loadData(InputInterface $input, OutputInterface $output): void;
|
||||
}
|
||||
|
||||
@@ -20,18 +20,12 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
abstract class AbstractRoleCommand extends Command
|
||||
{
|
||||
private $userService;
|
||||
|
||||
public function __construct(UserService $userService)
|
||||
public function __construct(private UserService $userService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->userService = $userService;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setDefinition([
|
||||
@@ -41,10 +35,7 @@ abstract class AbstractRoleCommand extends Command
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$username = $input->getArgument('username');
|
||||
$role = $input->getArgument('role');
|
||||
@@ -62,7 +53,7 @@ abstract class AbstractRoleCommand extends Command
|
||||
|
||||
$this->executeRoleCommand($this->userService, new SymfonyStyle($input, $output), $user, $super, $role);
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
abstract protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role);
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Validator\ValidationFailedException;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
abstract class AbstractUserCommand extends Command
|
||||
{
|
||||
@@ -37,4 +39,17 @@ abstract class AbstractUserCommand extends Command
|
||||
|
||||
return $helper->ask($input, $output, $passwordQuestion);
|
||||
}
|
||||
|
||||
protected function validationError(ValidationFailedException $exception, SymfonyStyle $style): void
|
||||
{
|
||||
$errors = $exception->getViolations();
|
||||
if ($errors->count() > 0) {
|
||||
/** @var \Symfony\Component\Validator\ConstraintViolation $error */
|
||||
foreach ($errors as $error) {
|
||||
$style->error(
|
||||
$error->getPropertyPath() . ': ' . $error->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,47 +10,38 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\User\UserService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class ActivateUserCommand extends Command
|
||||
#[AsCommand(name: 'kimai:user:activate')]
|
||||
final class ActivateUserCommand extends Command
|
||||
{
|
||||
private $userService;
|
||||
|
||||
public function __construct(UserService $userService)
|
||||
public function __construct(private UserService $userService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->userService = $userService;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:user:activate')
|
||||
->setAliases(['fos:user:activate'])
|
||||
->setDescription('Activate a user')
|
||||
->setDefinition([
|
||||
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
|
||||
])
|
||||
->setHelp(
|
||||
<<<'EOT'
|
||||
The <info>kimai:user:activate</info> command activates a user (so they will be able to log in):
|
||||
The <info>kimai:user:activate</info> command activates a user (so they will be able to log in):
|
||||
|
||||
<info>php %command.full_name% susan_super</info>
|
||||
EOT
|
||||
<info>php %command.full_name% susan_super</info>
|
||||
EOT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$username = $input->getArgument('username');
|
||||
$user = $this->userService->findUserByUsernameOrThrowException($username);
|
||||
@@ -65,6 +56,6 @@ EOT
|
||||
$io->warning(sprintf('User "%s" is already active.', $username));
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,27 +10,25 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\User\UserService;
|
||||
use App\Utils\CommandStyle;
|
||||
use App\Validator\ValidationFailedException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'kimai:user:password')]
|
||||
final class ChangePasswordCommand extends AbstractUserCommand
|
||||
{
|
||||
private $userService;
|
||||
|
||||
public function __construct(UserService $userService)
|
||||
public function __construct(private UserService $userService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->userService = $userService;
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:user:password')
|
||||
->setAliases(['fos:user:change-password'])
|
||||
->setDescription('Change the password of a user.')
|
||||
->setDefinition([
|
||||
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
|
||||
@@ -38,24 +36,21 @@ final class ChangePasswordCommand extends AbstractUserCommand
|
||||
])
|
||||
->setHelp(
|
||||
<<<'EOT'
|
||||
The <info>kimai:user:password</info> command changes the password of a user:
|
||||
The <info>kimai:user:password</info> command changes the password of a user:
|
||||
|
||||
<info>php %command.full_name% matthieu</info>
|
||||
<info>php %command.full_name% matthieu</info>
|
||||
|
||||
This interactive shell will first ask you for a password.
|
||||
This interactive shell will first ask you for a password.
|
||||
|
||||
You can alternatively specify the password as a second argument:
|
||||
You can alternatively specify the password as a second argument:
|
||||
|
||||
<info>php %command.full_name% susan_super newpassword</info>
|
||||
<info>php %command.full_name% susan_super newpassword</info>
|
||||
|
||||
EOT
|
||||
EOT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$username = $input->getArgument('username');
|
||||
|
||||
@@ -67,18 +62,18 @@ EOT
|
||||
|
||||
$user = $this->userService->findUserByUsernameOrThrowException($username);
|
||||
|
||||
$io = new CommandStyle($input, $output);
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
try {
|
||||
$user->setPlainPassword($password);
|
||||
$this->userService->updateUser($user, ['PasswordUpdate']);
|
||||
$io->success(sprintf('Changed password for user "%s".', $username));
|
||||
} catch (ValidationFailedException $ex) {
|
||||
$io->validationError($ex);
|
||||
$this->validationError($ex, $io);
|
||||
|
||||
return 2;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,32 +11,27 @@ namespace App\Command;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\User\UserService;
|
||||
use App\Utils\CommandStyle;
|
||||
use App\Validator\ValidationFailedException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'kimai:user:create')]
|
||||
final class CreateUserCommand extends AbstractUserCommand
|
||||
{
|
||||
private $userService;
|
||||
|
||||
public function __construct(UserService $userService)
|
||||
public function __construct(private UserService $userService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->userService = $userService;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$roles = implode(',', [User::DEFAULT_ROLE, User::ROLE_ADMIN]);
|
||||
|
||||
$this
|
||||
->setName('kimai:user:create')
|
||||
->setAliases(['kimai:create-user'])
|
||||
->setDescription('Create a new user')
|
||||
->setHelp('This command allows you to create a new user.')
|
||||
->addArgument('username', InputArgument::REQUIRED, 'A name for the new user (must be unique)')
|
||||
@@ -51,12 +46,9 @@ final class CreateUserCommand extends AbstractUserCommand
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new CommandStyle($input, $output);
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$username = $input->getArgument('username');
|
||||
$email = $input->getArgument('email');
|
||||
@@ -71,7 +63,7 @@ final class CreateUserCommand extends AbstractUserCommand
|
||||
$role = $role ?: User::DEFAULT_ROLE;
|
||||
|
||||
$user = $this->userService->createNewUser();
|
||||
$user->setUsername($username);
|
||||
$user->setUserIdentifier($username);
|
||||
$user->setPlainPassword($password);
|
||||
$user->setEmail($email);
|
||||
$user->setEnabled(true);
|
||||
@@ -81,11 +73,11 @@ final class CreateUserCommand extends AbstractUserCommand
|
||||
$this->userService->saveNewUser($user);
|
||||
$io->success(sprintf('Success! Created user: %s', $username));
|
||||
} catch (ValidationFailedException $ex) {
|
||||
$io->validationError($ex);
|
||||
$this->validationError($ex, $io);
|
||||
|
||||
return 2;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,47 +10,38 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\User\UserService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class DeactivateUserCommand extends Command
|
||||
#[AsCommand(name: 'kimai:user:deactivate')]
|
||||
final class DeactivateUserCommand extends Command
|
||||
{
|
||||
private $userService;
|
||||
|
||||
public function __construct(UserService $userService)
|
||||
public function __construct(private UserService $userService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->userService = $userService;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:user:deactivate')
|
||||
->setAliases(['fos:user:deactivate'])
|
||||
->setDescription('Deactivate a user')
|
||||
->setDefinition([
|
||||
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
|
||||
])
|
||||
->setHelp(
|
||||
<<<'EOT'
|
||||
The <info>kimai:user:deactivate</info> command deactivates a user (will not be able to log in)
|
||||
The <info>kimai:user:deactivate</info> command deactivates a user (will not be able to log in)
|
||||
|
||||
<info>php %command.full_name% susan_super</info>
|
||||
EOT
|
||||
<info>php %command.full_name% susan_super</info>
|
||||
EOT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$username = $input->getArgument('username');
|
||||
$user = $this->userService->findUserByUsernameOrThrowException($username);
|
||||
@@ -65,6 +56,6 @@ EOT
|
||||
$io->warning(sprintf('User "%s" is already deactivated.', $username));
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,28 +11,25 @@ namespace App\Command;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\User\UserService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class DemoteUserCommand extends AbstractRoleCommand
|
||||
#[AsCommand(name: 'kimai:user:demote')]
|
||||
final class DemoteUserCommand extends AbstractRoleCommand
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
parent::configure();
|
||||
|
||||
$this
|
||||
->setName('kimai:user:demote')
|
||||
->setAliases(['fos:user:demote'])
|
||||
->setDescription('Demote a user by removing a role')
|
||||
->setHelp(
|
||||
<<<'EOT'
|
||||
The <info>kimai:user:demote</info> command demotes a user by removing a role
|
||||
The <info>kimai:user:demote</info> command demotes a user by removing a role
|
||||
|
||||
<info>php %command.full_name% susan_super ROLE_TEAMLEAD</info>
|
||||
<info>php %command.full_name% --super susan_super</info>
|
||||
EOT
|
||||
<info>php %command.full_name% susan_super ROLE_TEAMLEAD</info>
|
||||
<info>php %command.full_name% --super susan_super</info>
|
||||
EOT
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +38,7 @@ EOT
|
||||
*/
|
||||
protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role)
|
||||
{
|
||||
$username = $user->getUsername();
|
||||
$username = $user->getUserIdentifier();
|
||||
if ($super) {
|
||||
if ($user->isSuperAdmin()) {
|
||||
$user->setSuperAdmin(false);
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\Export\ServiceExport;
|
||||
use App\Mail\KimaiMailer;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\ExportQuery;
|
||||
@@ -19,6 +18,7 @@ use App\Repository\TeamRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
@@ -27,35 +27,22 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Contracts\Translation\LocaleAwareInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class ExportCreateCommand extends Command
|
||||
#[AsCommand(name: 'kimai:export:create')]
|
||||
final class ExportCreateCommand extends Command
|
||||
{
|
||||
private $serviceExport;
|
||||
private $customerRepository;
|
||||
private $projectRepository;
|
||||
private $teamRepository;
|
||||
private $userRepository;
|
||||
private $translator;
|
||||
private $mailer;
|
||||
|
||||
public function __construct(
|
||||
ServiceExport $serviceExport,
|
||||
CustomerRepository $customerRepository,
|
||||
ProjectRepository $projectRepository,
|
||||
TeamRepository $teamRepository,
|
||||
UserRepository $userRepository,
|
||||
TranslatorInterface $translator,
|
||||
KimaiMailer $mailer
|
||||
private ServiceExport $serviceExport,
|
||||
private CustomerRepository $customerRepository,
|
||||
private ProjectRepository $projectRepository,
|
||||
private TeamRepository $teamRepository,
|
||||
private UserRepository $userRepository,
|
||||
private TranslatorInterface $translator,
|
||||
private MailerInterface $mailer
|
||||
) {
|
||||
$this->serviceExport = $serviceExport;
|
||||
$this->customerRepository = $customerRepository;
|
||||
$this->projectRepository = $projectRepository;
|
||||
$this->teamRepository = $teamRepository;
|
||||
$this->userRepository = $userRepository;
|
||||
$this->translator = $translator;
|
||||
$this->mailer = $mailer;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
@@ -65,7 +52,6 @@ class ExportCreateCommand extends Command
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setName('kimai:export:create')
|
||||
->setDescription('Create exports')
|
||||
->setHelp('Create exports by several different filters and sent them via email.')
|
||||
->addOption('username', null, InputOption::VALUE_REQUIRED, 'The user to be used for generating the export (e.g. used for permissions and decimal setting)')
|
||||
@@ -107,7 +93,7 @@ class ExportCreateCommand extends Command
|
||||
default:
|
||||
$io->error('Unknown "exported" filter given');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$locale = $input->getOption('locale');
|
||||
@@ -151,18 +137,18 @@ class ExportCreateCommand extends Command
|
||||
if ($template === null) {
|
||||
$io->error('You must pass the "template" option');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$renderer = $this->serviceExport->getRendererById($template);
|
||||
if ($renderer === null) {
|
||||
$io->error('Unknown export "template", available are:');
|
||||
$rows = [];
|
||||
foreach ($this->serviceExport->getRenderer() as $renderer) {
|
||||
$rows[] = [$renderer->getId()];
|
||||
foreach ($this->serviceExport->getRenderer() as $tmp) {
|
||||
$rows[] = [$tmp->getId()];
|
||||
}
|
||||
$io->table(['ID'], $rows);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$start = $input->getOption('start');
|
||||
@@ -172,7 +158,7 @@ class ExportCreateCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Invalid start date given');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
if (!$start instanceof \DateTime) {
|
||||
@@ -187,7 +173,7 @@ class ExportCreateCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Invalid end date given');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,10 +181,6 @@ class ExportCreateCommand extends Command
|
||||
$end = $dateFactory->getEndOfMonth($start);
|
||||
}
|
||||
|
||||
if (!$end instanceof \DateTime) {
|
||||
$end = $dateFactory->getEndOfMonth();
|
||||
}
|
||||
|
||||
$end->setTime(23, 59, 59);
|
||||
|
||||
$directory = rtrim(sys_get_temp_dir(), '/') . '/';
|
||||
@@ -209,7 +191,7 @@ class ExportCreateCommand extends Command
|
||||
if (!is_dir($directory) || !is_writable($directory)) {
|
||||
$io->error('Invalid "directory" given: ' . $directory);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$subject = 'Export data available';
|
||||
@@ -223,7 +205,7 @@ class ExportCreateCommand extends Command
|
||||
if ($result === false) {
|
||||
$io->error('Invalid "email" given: ' . $email);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$emails[] = $email;
|
||||
}
|
||||
@@ -240,14 +222,15 @@ class ExportCreateCommand extends Command
|
||||
$query = new ExportQuery();
|
||||
|
||||
$username = $input->getOption('username');
|
||||
if (!empty($username)) {
|
||||
$user = $this->userRepository->loadUserByUsername($username);
|
||||
if (null === $user) {
|
||||
if (\is_string($username) && !empty($username)) {
|
||||
try {
|
||||
$user = $this->userRepository->loadUserByIdentifier($username);
|
||||
} catch(\Exception) {
|
||||
$io->error(
|
||||
sprintf('The given username "%s" could not be resolved', $username)
|
||||
);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$query->setCurrentUser($user);
|
||||
}
|
||||
@@ -268,7 +251,7 @@ class ExportCreateCommand extends Command
|
||||
if (\count($entries) === 0) {
|
||||
$io->success('No entries found, skipping');
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$response = $renderer->render($entries, $query);
|
||||
@@ -299,7 +282,7 @@ class ExportCreateCommand extends Command
|
||||
$io->success('Saved export to: ' . $file);
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function savePreview(Response $response, string $directory): string
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Importer\ImporterService;
|
||||
use App\Importer\ImportNotFoundException;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* This command can change anytime, don't rely on its API for the future!
|
||||
*/
|
||||
class ImportCustomerCommand extends Command
|
||||
{
|
||||
private $importer;
|
||||
|
||||
public function __construct(ImporterService $importer)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->importer = $importer;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setName('kimai:import:customer')
|
||||
->setDescription('Import customer from CSV file')
|
||||
->setHelp(
|
||||
'Import customers from a CSV file.' . PHP_EOL .
|
||||
'Customer will be matched by name or number, and if not found created on the fly.' . PHP_EOL
|
||||
)
|
||||
->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported')
|
||||
->addOption('importer', null, InputOption::VALUE_REQUIRED, 'The importer to use (supported: default, grandtotal)', 'default')
|
||||
->addOption('reader', null, InputOption::VALUE_REQUIRED, 'The reader to use (supported: csv, csv-semicolon)', 'csv')
|
||||
->addOption('no-update', null, InputOption::VALUE_NONE, 'If you want to create new customers, but not update existing ones')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int|null
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Kimai importer: Customers');
|
||||
|
||||
$skipUpdate = $input->getOption('no-update');
|
||||
$doImport = true;
|
||||
$row = 1;
|
||||
$errors = 0;
|
||||
$customers = [];
|
||||
$importer = null;
|
||||
|
||||
try {
|
||||
$importer = $this->importer->getCustomerImporter($input->getOption('importer'));
|
||||
$reader = $this->importer->getReader($input->getOption('reader'));
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$importerFile = $input->getArgument('file');
|
||||
|
||||
try {
|
||||
$records = $reader->read($importerFile);
|
||||
} catch (ImportNotFoundException $ex) {
|
||||
$io->error('File not existing or not readable: ' . $importerFile);
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
$amount = iterator_count($records);
|
||||
$records->rewind();
|
||||
$io->text(sprintf('Found %s rows to process, converting now ...', $amount));
|
||||
|
||||
$progressBar = new ProgressBar($output, $amount);
|
||||
|
||||
foreach ($records as $record) {
|
||||
try {
|
||||
$customers[] = $importer->convertEntryToCustomer($record);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(sprintf('Invalid row %s: %s', $row, $ex->getMessage()));
|
||||
$doImport = false;
|
||||
$errors++;
|
||||
}
|
||||
$progressBar->advance();
|
||||
|
||||
$row++;
|
||||
}
|
||||
$progressBar->finish();
|
||||
$io->writeln('');
|
||||
|
||||
if (!$doImport) {
|
||||
$io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors));
|
||||
|
||||
return 3;
|
||||
}
|
||||
|
||||
$amount = \count($customers);
|
||||
$io->text(sprintf('Converted %s customers, importing into Kimai now ...', $amount));
|
||||
|
||||
$progressBar = new ProgressBar($output, $amount);
|
||||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$noUpdatedCustomers = 0;
|
||||
|
||||
foreach ($customers as $customer) {
|
||||
try {
|
||||
$progressBar->advance();
|
||||
|
||||
if ($customer->getId() === null) {
|
||||
$this->importer->importCustomer($customer);
|
||||
$created++;
|
||||
} elseif ($skipUpdate === false) {
|
||||
$this->importer->importCustomer($customer);
|
||||
$updated++;
|
||||
} else {
|
||||
$noUpdatedCustomers++;
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(sprintf('Failed importing customer "%s" with: %s', $customer->getName(), $ex->getMessage()));
|
||||
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$io->writeln('');
|
||||
$io->writeln('');
|
||||
|
||||
if ($created > 0) {
|
||||
$io->success(sprintf('Imported %s customer', $created));
|
||||
}
|
||||
if ($updated > 0) {
|
||||
$io->success(sprintf('Updated %s customer', $updated));
|
||||
}
|
||||
if ($noUpdatedCustomers > 0) {
|
||||
$io->success(sprintf('Skipped %s existing customer', $noUpdatedCustomers));
|
||||
}
|
||||
|
||||
if ($updated === 0 && $created === 0) {
|
||||
$io->text('Nothing was imported');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Team;
|
||||
use App\Importer\ImporterService;
|
||||
use App\Importer\ImportNotFoundException;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Validator\ValidationFailedException;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* This command can change anytime, don't rely on its API for the future!
|
||||
*/
|
||||
class ImportProjectCommand extends Command
|
||||
{
|
||||
private $importerService;
|
||||
private $teams;
|
||||
private $users;
|
||||
|
||||
public function __construct(ImporterService $importerService, TeamRepository $teams, UserRepository $users)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->importerService = $importerService;
|
||||
$this->teams = $teams;
|
||||
$this->users = $users;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setName('kimai:import:project')
|
||||
->setDescription('Import projects from CSV file')
|
||||
->setHelp(
|
||||
'Import projects from a CSV file, creating customers (if not existing) and optional empty teams for each project.' . PHP_EOL .
|
||||
'Imported customer will be matched by name and optionally created on the fly.' . PHP_EOL
|
||||
)
|
||||
->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported')
|
||||
->addOption('importer', null, InputOption::VALUE_REQUIRED, 'The importer to use (supported: default)', 'default')
|
||||
->addOption('reader', null, InputOption::VALUE_REQUIRED, 'The reader to use (supported: csv, csv-semicolon)', 'csv')
|
||||
->addOption('teamlead', null, InputOption::VALUE_REQUIRED, 'If you want to create empty teams for each project, give the username of the teamlead to be assigned')
|
||||
->addOption('no-update', null, InputOption::VALUE_NONE, 'If you want to create new project, but not update existing ones')
|
||||
->addOption('date-format', null, InputOption::VALUE_REQUIRED, 'Date format for imports', 'Y-m-d')
|
||||
->addOption('timezone', null, InputOption::VALUE_REQUIRED, 'Timezone for imports', date_default_timezone_get())
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int|null
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Kimai importer: Projects');
|
||||
|
||||
// validate teamlead
|
||||
$teamlead = $input->getOption('teamlead');
|
||||
if (null !== $teamlead) {
|
||||
$tmpUser = $this->users->findOneBy(['username' => $teamlead]);
|
||||
if ($tmpUser === null) {
|
||||
$tmpUser = $this->users->findOneBy(['email' => $teamlead]);
|
||||
if ($tmpUser === null) {
|
||||
$io->error(
|
||||
sprintf(
|
||||
'You requested to create empty teams for each project, but the given teamlead cannot be found.' . PHP_EOL .
|
||||
'Please create a user with the name (or email) %s first, before continuing.' . PHP_EOL,
|
||||
$teamlead
|
||||
)
|
||||
);
|
||||
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
$teamlead = $tmpUser;
|
||||
}
|
||||
|
||||
$skipUpdate = $input->getOption('no-update');
|
||||
$doImport = true;
|
||||
$row = 1;
|
||||
$errors = 0;
|
||||
$projects = [];
|
||||
|
||||
try {
|
||||
$importer = $this->importerService->getProjectImporter($input->getOption('importer'));
|
||||
$reader = $this->importerService->getReader($input->getOption('reader'));
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$importerFile = $input->getArgument('file');
|
||||
|
||||
$io->text('Reading import file ...');
|
||||
|
||||
try {
|
||||
$records = $reader->read($importerFile);
|
||||
} catch (ImportNotFoundException $ex) {
|
||||
$io->error('File not existing or not readable: ' . $importerFile);
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
$amount = iterator_count($records);
|
||||
$records->rewind();
|
||||
$io->text(sprintf('Found %s rows to process, converting now ...', $amount));
|
||||
|
||||
$progressBar = new ProgressBar($output, $amount);
|
||||
|
||||
$options = [];
|
||||
if (null !== ($dateFormat = $input->getOption('date-format'))) {
|
||||
$options['dateformat'] = $dateFormat;
|
||||
}
|
||||
if (null !== ($timezone = $input->getOption('timezone'))) {
|
||||
$options['timezone'] = $timezone;
|
||||
}
|
||||
|
||||
foreach ($records as $record) {
|
||||
try {
|
||||
$projects[] = $importer->convertEntryToProject($record, $options);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(sprintf('Invalid row %s: %s', $row, $ex->getMessage()));
|
||||
$doImport = false;
|
||||
$errors++;
|
||||
}
|
||||
$progressBar->advance();
|
||||
|
||||
$row++;
|
||||
}
|
||||
$progressBar->finish();
|
||||
$io->writeln('');
|
||||
|
||||
if (!$doImport) {
|
||||
$io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors));
|
||||
|
||||
return 3;
|
||||
}
|
||||
|
||||
$createdProjects = 0;
|
||||
$updatedProjects = 0;
|
||||
$noUpdatedProjects = 0;
|
||||
$createdCustomers = 0;
|
||||
$createdTeams = 0;
|
||||
|
||||
$amount = \count($projects);
|
||||
$io->text(sprintf('Converted %s projects, importing into Kimai now ...', $amount));
|
||||
|
||||
$progressBar = new ProgressBar($output, $amount);
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$progressBar->advance();
|
||||
try {
|
||||
if ($project->getCustomer()->getId() === null) {
|
||||
$this->importerService->importCustomer($project->getCustomer());
|
||||
$createdCustomers++;
|
||||
}
|
||||
|
||||
$createTeam = false;
|
||||
|
||||
if ($project->getId() === null) {
|
||||
$this->importerService->importProject($project);
|
||||
$createdProjects++;
|
||||
$createTeam = (null !== $teamlead);
|
||||
} elseif ($skipUpdate === false) {
|
||||
$this->importerService->importProject($project);
|
||||
$updatedProjects++;
|
||||
} else {
|
||||
$noUpdatedProjects++;
|
||||
}
|
||||
|
||||
if (!$createTeam) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$team = new Team();
|
||||
$team->setName($project->getName());
|
||||
$team->addTeamlead($teamlead);
|
||||
|
||||
$this->teams->saveTeam($team);
|
||||
|
||||
$project->addTeam($team);
|
||||
$team->addProject($project);
|
||||
|
||||
$this->teams->saveTeam($team);
|
||||
$createdTeams++;
|
||||
} catch (ValidationFailedException $ex) {
|
||||
$io->error(sprintf('Failed importing project "%s" with: %s', $project->getName(), $ex->getMessage()));
|
||||
for ($i = 0; $i < $ex->getViolations()->count(); $i++) {
|
||||
$violation = $ex->getViolations()->get($i);
|
||||
$io->error(sprintf('Failed validating field "%s" with value "%s": %s', $violation->getPropertyPath(), $violation->getInvalidValue(), $violation->getMessage()));
|
||||
}
|
||||
|
||||
return 4;
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(sprintf('Failed importing project "%s" with: %s', $project->getName(), $ex->getMessage()));
|
||||
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$io->writeln('');
|
||||
$io->writeln('');
|
||||
|
||||
if ($createdCustomers === 0 && $updatedProjects === 0 && $createdProjects === 0) {
|
||||
if ($noUpdatedProjects > 0) {
|
||||
$io->success(sprintf('Skipped %s existing projects', $noUpdatedProjects));
|
||||
} else {
|
||||
$io->text('Nothing was imported');
|
||||
}
|
||||
} else {
|
||||
if ($createdCustomers > 0) {
|
||||
$io->success(sprintf('Imported %s customers', $createdCustomers));
|
||||
}
|
||||
if ($updatedProjects > 0) {
|
||||
$io->success(sprintf('Updated %s projects', $updatedProjects));
|
||||
}
|
||||
if ($noUpdatedProjects > 0) {
|
||||
$io->success(sprintf('Skipped %s existing projects', $noUpdatedProjects));
|
||||
}
|
||||
if ($createdProjects > 0) {
|
||||
$io->success(sprintf('Imported %s projects', $createdProjects));
|
||||
}
|
||||
if ($createdTeams > 0) {
|
||||
$io->success(sprintf('Created %s teams', $createdTeams));
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -1,726 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Tag;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Importer\InvalidFieldsException;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TagRepository;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Utils\Duration;
|
||||
use League\Csv\Reader;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
|
||||
|
||||
/**
|
||||
* This command can change anytime, don't rely on its API for the future!
|
||||
*
|
||||
* @internal
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class ImportTimesheetCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'kimai:import:timesheet';
|
||||
|
||||
// if we use 00:00 we might run into summer/winter time problems which happen between 02:00 and 03:00
|
||||
public const DEFAULT_BEGIN = '04:00';
|
||||
public const DEFAULT_CUSTOMER = 'Imported customer - %s';
|
||||
|
||||
private static $supportedHeader = [
|
||||
'Date',
|
||||
'From',
|
||||
'To',
|
||||
'Duration',
|
||||
'Rate',
|
||||
'User',
|
||||
'Customer',
|
||||
'Project',
|
||||
'Activity',
|
||||
'Description',
|
||||
'Exported',
|
||||
'Tags',
|
||||
'Hourly rate',
|
||||
'Fixed rate',
|
||||
];
|
||||
|
||||
private $customers;
|
||||
private $projects;
|
||||
private $activities;
|
||||
private $users;
|
||||
private $tagRepository;
|
||||
private $timesheets;
|
||||
private $configuration;
|
||||
private $encoder;
|
||||
|
||||
/**
|
||||
* @var Customer
|
||||
*/
|
||||
private $customerFallback;
|
||||
/**
|
||||
* @var Customer[]
|
||||
*/
|
||||
private $customerCache = [];
|
||||
/**
|
||||
* @var Project[]
|
||||
*/
|
||||
private $projectCache = [];
|
||||
/**
|
||||
* @var User[]
|
||||
*/
|
||||
private $userCache = [];
|
||||
/**
|
||||
* @var Tag[]
|
||||
*/
|
||||
private $tagCache = [];
|
||||
/**
|
||||
* Comment that will be added to new customers, projects and activities.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $comment = '';
|
||||
/**
|
||||
* The datetime of this import as formatted string.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $dateTime = '';
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $begin = self::DEFAULT_BEGIN;
|
||||
// some statistics to display to the user
|
||||
private $createdProjects = 0;
|
||||
private $createdUsers = 0;
|
||||
private $createdCustomers = 0;
|
||||
private $createdActivities = 0;
|
||||
|
||||
public function __construct(
|
||||
CustomerRepository $customers,
|
||||
ProjectRepository $projects,
|
||||
ActivityRepository $activities,
|
||||
UserRepository $users,
|
||||
TagRepository $tagRepository,
|
||||
TimesheetRepository $timesheets,
|
||||
SystemConfiguration $configuration,
|
||||
UserPasswordEncoderInterface $encoder
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->customers = $customers;
|
||||
$this->projects = $projects;
|
||||
$this->activities = $activities;
|
||||
$this->users = $users;
|
||||
$this->tagRepository = $tagRepository;
|
||||
$this->timesheets = $timesheets;
|
||||
$this->configuration = $configuration;
|
||||
$this->encoder = $encoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setName(self::$defaultName)
|
||||
->setDescription('Import timesheets from CSV file')
|
||||
->setHelp(
|
||||
'This command allows to import timesheets from a CSV file, which are formatted like CSV exports.' . PHP_EOL .
|
||||
'Imported customer, projects and activities will be matched by name.' . PHP_EOL .
|
||||
'Supported columns names: ' . implode(', ', self::$supportedHeader) . PHP_EOL
|
||||
)
|
||||
->addOption('timezone', null, InputOption::VALUE_OPTIONAL, 'The timezone to be used. Supports: "valid timezone names", the string "user" (using the configured users timezone) and the string "server" (PHP default timezone)', 'user')
|
||||
->addOption('customer', null, InputOption::VALUE_OPTIONAL, 'A customer ID or name to assign for empty entries. Defaults to creating a new customer which is used for all un-linked projects')
|
||||
->addOption('activity', null, InputOption::VALUE_OPTIONAL, 'Whether new activities should be "global" or "project" specific. Allowed values are "global" and "project"', 'project')
|
||||
->addOption('delimiter', null, InputOption::VALUE_OPTIONAL, 'The CSV field delimiter', ',')
|
||||
->addOption('begin', null, InputOption::VALUE_OPTIONAL, 'Default begin if none was provided in the format HH:MM', self::DEFAULT_BEGIN)
|
||||
->addOption('comment', null, InputOption::VALUE_OPTIONAL, 'A description to be added to created customers, projects and activities. %s will be replaced with the current datetime', 'Created by import at %s')
|
||||
->addOption('create-users', null, InputOption::VALUE_NONE, 'If set, accounts for not found users will be created')
|
||||
->addOption('ignore-errors', null, InputOption::VALUE_NONE, 'If set, invalid rows will be skipped')
|
||||
->addOption('batch', null, InputOption::VALUE_NONE, 'If set, timesheets will be written in batches of 100')
|
||||
->addOption('domain', null, InputOption::VALUE_OPTIONAL, 'Domain name used for email addresses of new created users. If provided usernames already include a domain, this option will be skipped.', 'example.com')
|
||||
->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Password for new created users.', 'password')
|
||||
->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int|null
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Kimai importer: Timesheets');
|
||||
|
||||
$csvFile = $input->getArgument('file');
|
||||
if (!file_exists($csvFile)) {
|
||||
$io->error('File not existing: ' . $csvFile);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!is_readable($csvFile)) {
|
||||
$io->error('File cannot be read: ' . $csvFile);
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
$this->dateTime = (new \DateTime())->format('Y.m.d H:i');
|
||||
$this->comment = sprintf($input->getOption('comment'), $this->dateTime);
|
||||
$this->begin = $input->getOption('begin');
|
||||
|
||||
$timezone = $input->getOption('timezone');
|
||||
switch ($timezone) {
|
||||
case 'server':
|
||||
$timezone = new \DateTimeZone(date_default_timezone_get());
|
||||
break;
|
||||
|
||||
case 'user':
|
||||
// null means fetch from user
|
||||
$timezone = null;
|
||||
break;
|
||||
|
||||
default:
|
||||
try {
|
||||
if (!\in_array($timezone, \DateTimeZone::listIdentifiers())) {
|
||||
throw new \InvalidArgumentException('Not a known PHP timezone');
|
||||
}
|
||||
$timezone = new \DateTimeZone($timezone);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Invalid timezone given, import canceled.');
|
||||
|
||||
return 3;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$activityType = $input->getOption('activity');
|
||||
$allowedActivityTypes = ['project', 'global'];
|
||||
if (!\in_array($activityType, $allowedActivityTypes)) {
|
||||
$io->error(sprintf('Invalid activity type "%s" given, allowed values are: %s', $activityType, implode(', ', $allowedActivityTypes)));
|
||||
|
||||
return 4;
|
||||
}
|
||||
|
||||
$csv = Reader::createFromPath($csvFile, 'r');
|
||||
$csv->setDelimiter($input->getOption('delimiter'));
|
||||
$csv->setHeaderOffset(0);
|
||||
$header = $csv->getHeader();
|
||||
if (!$this->validateHeader($header)) {
|
||||
$io->error(
|
||||
sprintf(
|
||||
'Found invalid CSV. The header: ' . PHP_EOL .
|
||||
'%s' . PHP_EOL .
|
||||
'did not match the expected structure: ' . PHP_EOL .
|
||||
'%s',
|
||||
implode(', ', $header),
|
||||
implode(', ', self::$supportedHeader)
|
||||
)
|
||||
);
|
||||
|
||||
return 5;
|
||||
}
|
||||
|
||||
$all = $csv->getRecords();
|
||||
$total = iterator_count($all);
|
||||
|
||||
$io->text(sprintf('Found %s timesheets to import, pre-validating now', $total));
|
||||
|
||||
$records = [];
|
||||
$doImport = true;
|
||||
$row = 1;
|
||||
$errors = 0;
|
||||
|
||||
$createUsers = $input->getOption('create-users');
|
||||
$ignoreErrors = $input->getOption('ignore-errors');
|
||||
|
||||
// ======================= validate rows =======================
|
||||
$progressBar = new ProgressBar($output, $total);
|
||||
|
||||
$countAll = 0;
|
||||
foreach ($all as $record) {
|
||||
$this->convertRow($record);
|
||||
try {
|
||||
$this->validateRow($record);
|
||||
} catch (InvalidFieldsException $ex) {
|
||||
$io->error(sprintf('Invalid row %s, invalid fields: %s', $row, implode(', ', $ex->getFields())));
|
||||
$doImport = false;
|
||||
$errors++;
|
||||
}
|
||||
|
||||
if (!$createUsers) {
|
||||
if (null === $this->getUser($record['User'])) {
|
||||
if (!$ignoreErrors) {
|
||||
$io->error(sprintf('Unknown user %s in row %s', $record['User'], $row));
|
||||
}
|
||||
$doImport = false;
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
$row++;
|
||||
|
||||
if ($doImport) {
|
||||
$records[] = $record;
|
||||
}
|
||||
$countAll++;
|
||||
$progressBar->advance();
|
||||
}
|
||||
$progressBar->finish();
|
||||
$io->writeln('');
|
||||
|
||||
if (!$ignoreErrors && !$doImport) {
|
||||
$io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors));
|
||||
|
||||
return 5;
|
||||
}
|
||||
|
||||
$io->writeln('');
|
||||
$io->text(sprintf('Processing %s of %s rows, skipping %s with pre-validation errors.', \count($records), iterator_count($all), $errors));
|
||||
|
||||
// values for new users
|
||||
$password = $input->getOption('password');
|
||||
$domain = $input->getOption('domain');
|
||||
|
||||
$progressBar = new ProgressBar($output, \count($records));
|
||||
|
||||
$durationParser = new Duration();
|
||||
$row = 0;
|
||||
$imported = 0;
|
||||
$failed = 0;
|
||||
|
||||
$isBatchUpdate = $input->getOption('batch');
|
||||
$batches = [];
|
||||
|
||||
foreach ($records as $record) {
|
||||
$row++;
|
||||
try {
|
||||
$project = $this->getProject($record['Project'], $record['Customer'], $input->getOption('customer'));
|
||||
$activity = $this->getActivity($record['Activity'], $project, $activityType);
|
||||
|
||||
$user = $this->getUser($record['User']);
|
||||
if (null === $user) {
|
||||
$user = $this->createUser($record['User'], $domain, $password);
|
||||
}
|
||||
|
||||
$begin = null;
|
||||
$end = null;
|
||||
$duration = 0;
|
||||
|
||||
if (!empty($record['Duration'])) {
|
||||
if (\is_int($record['Duration'])) {
|
||||
$duration = $record['Duration'];
|
||||
} else {
|
||||
$duration = $durationParser->parseDurationString($record['Duration']);
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $timezone) {
|
||||
$timezone = new \DateTimeZone($user->getTimezone());
|
||||
}
|
||||
|
||||
if (empty($record['From']) && empty($record['To'])) {
|
||||
$begin = new \DateTime($record['Date'] . ' ' . $this->begin, $timezone);
|
||||
$end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration);
|
||||
} elseif (empty($record['From'])) {
|
||||
$end = new \DateTime($record['Date'] . ' ' . $record['To'], $timezone);
|
||||
$begin = (new \DateTime())->setTimezone($timezone)->setTimestamp($end->getTimestamp() - $duration);
|
||||
} elseif (empty($record['To'])) {
|
||||
$begin = new \DateTime($record['Date'] . ' ' . $record['From'], $timezone);
|
||||
$end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration);
|
||||
} else {
|
||||
$begin = new \DateTime($record['Date'] . ' ' . $record['From'], $timezone);
|
||||
$end = new \DateTime($record['Date'] . ' ' . $record['To'], $timezone);
|
||||
|
||||
// fix dates, which are running over midnight
|
||||
if ($end < $begin) {
|
||||
if ($duration > 0) {
|
||||
$end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration);
|
||||
} else {
|
||||
$end->add(new \DateInterval('P1D'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setActivity($activity);
|
||||
$timesheet->setProject($project);
|
||||
$timesheet->setBegin($begin);
|
||||
$timesheet->setEnd($end);
|
||||
$timesheet->setUser($user);
|
||||
$timesheet->setDescription($record['Description']);
|
||||
$timesheet->setExported((bool) $record['Exported']);
|
||||
|
||||
if (!empty($record['Tags'])) {
|
||||
foreach (explode(',', $record['Tags']) as $tagName) {
|
||||
if (empty($tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tag = $this->getTag($tagName);
|
||||
|
||||
$timesheet->addTag($tag);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($record['Rate'])) {
|
||||
$timesheet->setRate($record['Rate']);
|
||||
}
|
||||
if (!empty($record['Hourly rate'])) {
|
||||
$timesheet->setHourlyRate($record['Hourly rate']);
|
||||
}
|
||||
if (!empty($record['Fixed rate'])) {
|
||||
$timesheet->setFixedRate($record['Fixed rate']);
|
||||
}
|
||||
|
||||
if ($isBatchUpdate) {
|
||||
$batches[] = $timesheet;
|
||||
|
||||
if ($row % 100 === 0) {
|
||||
$this->timesheets->saveMultiple($batches);
|
||||
$batches = [];
|
||||
}
|
||||
} else {
|
||||
$this->timesheets->save($timesheet);
|
||||
}
|
||||
|
||||
$imported++;
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(sprintf('Failed importing timesheet row %s with: %s', $row, $ex->getMessage()));
|
||||
$failed++;
|
||||
}
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
if ($isBatchUpdate && \count($batches) > 0) {
|
||||
$this->timesheets->saveMultiple($batches);
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
|
||||
$io->writeln('');
|
||||
$io->writeln('');
|
||||
|
||||
if ($this->createdUsers > 0) {
|
||||
$io->success(sprintf('Created %s users', $this->createdUsers));
|
||||
}
|
||||
if ($this->createdCustomers > 0) {
|
||||
$io->success(sprintf('Created %s customers', $this->createdCustomers));
|
||||
}
|
||||
if ($this->createdProjects > 0) {
|
||||
$io->success(sprintf('Created %s projects', $this->createdProjects));
|
||||
}
|
||||
if ($this->createdActivities > 0) {
|
||||
$io->success(sprintf('Created %s activities', $this->createdActivities));
|
||||
}
|
||||
|
||||
if ($failed > 0) {
|
||||
$io->warning(sprintf('Failed validating %s rows', $failed));
|
||||
}
|
||||
|
||||
if ($imported > 0) {
|
||||
$io->success(sprintf('Imported %s rows', $imported));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function createUser($username, $domain, $password): User
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername($username);
|
||||
if (stripos($username, '@') === false) {
|
||||
$email = preg_replace('/[[:^print:]]/', '', $username) . '@' . $domain;
|
||||
$email = strtolower($email);
|
||||
} else {
|
||||
$email = $username;
|
||||
}
|
||||
$user->setEmail($email);
|
||||
$user->setPassword($this->encoder->encodePassword($user, $password));
|
||||
|
||||
$this->users->saveUser($user);
|
||||
$this->createdUsers++;
|
||||
|
||||
$this->userCache[$username] = $user;
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function getUser($user): ?User
|
||||
{
|
||||
if (!\array_key_exists($user, $this->userCache)) {
|
||||
$tmpUser = $this->users->findOneBy(['username' => $user]);
|
||||
if (null === $tmpUser) {
|
||||
$tmpUser = $this->users->findOneBy(['email' => $user]);
|
||||
if (null === $tmpUser) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
$this->userCache[$user] = $tmpUser;
|
||||
}
|
||||
|
||||
return $this->userCache[$user];
|
||||
}
|
||||
|
||||
private function getTag(string $tagName): Tag
|
||||
{
|
||||
if (\array_key_exists($tagName, $this->tagCache)) {
|
||||
return $this->tagCache[$tagName];
|
||||
}
|
||||
|
||||
$tag = $this->tagRepository->findTagByName($tagName);
|
||||
|
||||
if ($tag === null) {
|
||||
$tag = (new Tag())->setName($tagName);
|
||||
}
|
||||
|
||||
$this->tagCache[$tagName] = $tag;
|
||||
|
||||
return $this->tagCache[$tagName];
|
||||
}
|
||||
|
||||
private function getActivity($activity, Project $project, $activityType): Activity
|
||||
{
|
||||
$tmpActivity = null;
|
||||
|
||||
$tmpActivities = $this->activities->findBy(['project' => $project->getId(), 'name' => $activity]);
|
||||
|
||||
if (\count($tmpActivities) === 0) {
|
||||
$tmpActivity = $this->activities->findOneBy(['project' => null, 'name' => $activity]);
|
||||
} elseif (\count($tmpActivities) === 1) {
|
||||
$tmpActivity = $tmpActivities[0];
|
||||
}
|
||||
|
||||
if (null === $tmpActivity) {
|
||||
$tmpActivity = new Activity();
|
||||
$tmpActivity->setName($activity);
|
||||
$tmpActivity->setComment($this->comment);
|
||||
if ($activityType === 'project') {
|
||||
$tmpActivity->setProject($project);
|
||||
}
|
||||
$this->activities->saveActivity($tmpActivity);
|
||||
$this->createdActivities++;
|
||||
}
|
||||
|
||||
return $tmpActivity;
|
||||
}
|
||||
|
||||
private function getProject($project, $customer, $fallbackCustomer): Project
|
||||
{
|
||||
$cacheKey = $project . '_____' . $customer;
|
||||
|
||||
if (!\array_key_exists($cacheKey, $this->projectCache)) {
|
||||
$tmpCustomer = $this->getCustomer($customer, $fallbackCustomer);
|
||||
/** @var Project $tmpProject */
|
||||
$tmpProject = null;
|
||||
/** @var Project[] $tmpProjects */
|
||||
$tmpProjects = $this->projects->findBy(['name' => $project]);
|
||||
|
||||
if (\count($tmpProjects) > 1) {
|
||||
/** @var Project $prj */
|
||||
foreach ($tmpProjects as $prj) {
|
||||
if (strcasecmp($prj->getCustomer()->getName(), $tmpCustomer->getName()) !== 0) {
|
||||
continue;
|
||||
}
|
||||
$tmpProject = $prj;
|
||||
break;
|
||||
}
|
||||
} elseif (\count($tmpProjects) === 1) {
|
||||
$tmpProject = $tmpProjects[0];
|
||||
}
|
||||
|
||||
if (null !== $tmpProject) {
|
||||
if (strcasecmp($tmpProject->getCustomer()->getName(), $tmpCustomer->getName()) !== 0) {
|
||||
$tmpProject = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($tmpProject === null) {
|
||||
$tmpProject = new Project();
|
||||
$tmpProject->setName($project);
|
||||
$tmpProject->setComment($this->comment);
|
||||
$tmpProject->setCustomer($tmpCustomer);
|
||||
$this->projects->saveProject($tmpProject);
|
||||
$this->createdProjects++;
|
||||
}
|
||||
|
||||
$this->projectCache[$cacheKey] = $tmpProject;
|
||||
}
|
||||
|
||||
return $this->projectCache[$cacheKey];
|
||||
}
|
||||
|
||||
private function getCustomer($customer, $fallback): Customer
|
||||
{
|
||||
if (!empty($customer)) {
|
||||
if (!\array_key_exists($customer, $this->customerCache)) {
|
||||
$tmpCustomer = $this->customers->findBy(['name' => $customer]);
|
||||
if (\count($tmpCustomer) > 1) {
|
||||
throw new \Exception(sprintf('Found multiple customers with the name: %s', $customer));
|
||||
} elseif (\count($tmpCustomer) === 1) {
|
||||
$tmpCustomer = $tmpCustomer[0];
|
||||
}
|
||||
|
||||
if ($tmpCustomer instanceof Customer) {
|
||||
$this->customerCache[$customer] = $tmpCustomer;
|
||||
}
|
||||
}
|
||||
|
||||
if (\array_key_exists($customer, $this->customerCache)) {
|
||||
return $this->customerCache[$customer];
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $this->customerFallback && !empty($fallback)) {
|
||||
return $this->customerFallback;
|
||||
}
|
||||
|
||||
$tmpFallback = null;
|
||||
|
||||
if (!empty($fallback)) {
|
||||
if (is_numeric($fallback)) {
|
||||
$tmpFallback = $this->customers->find((int) $fallback);
|
||||
} else {
|
||||
/** @var Customer|null $tmpFallback */
|
||||
$tmpFallback = $this->customers->findOneBy(['name' => $fallback]);
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $tmpFallback) {
|
||||
$newName = $customer;
|
||||
if (empty($customer)) {
|
||||
$newName = self::DEFAULT_CUSTOMER;
|
||||
if (!empty($fallback) && \is_string($fallback)) {
|
||||
$newName = $fallback;
|
||||
}
|
||||
}
|
||||
$tmpFallback = new Customer();
|
||||
$tmpFallback->setName(sprintf($newName, $this->dateTime));
|
||||
$tmpFallback->setComment($this->comment);
|
||||
$tmpFallback->setCountry($this->configuration->getCustomerDefaultCountry());
|
||||
$timezone = date_default_timezone_get();
|
||||
if (null !== $this->configuration->getCustomerDefaultTimezone()) {
|
||||
$timezone = $this->configuration->getCustomerDefaultTimezone();
|
||||
}
|
||||
$tmpFallback->setTimezone($timezone);
|
||||
$this->customers->saveCustomer($tmpFallback);
|
||||
$this->createdCustomers++;
|
||||
}
|
||||
|
||||
$this->customerFallback = $tmpFallback;
|
||||
|
||||
return $this->customerFallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $row
|
||||
* @return bool
|
||||
* @throws InvalidFieldsException
|
||||
*/
|
||||
private function validateRow(array $row)
|
||||
{
|
||||
$fields = [];
|
||||
|
||||
if (empty($row['Project'])) {
|
||||
$fields[] = 'Project';
|
||||
}
|
||||
|
||||
if (empty($row['Activity'])) {
|
||||
$fields[] = 'Activity';
|
||||
}
|
||||
|
||||
if (empty($row['Date'])) {
|
||||
$fields[] = 'Date';
|
||||
}
|
||||
|
||||
if ((empty($row['From']) || empty($row['To'])) && empty($row['Duration'])) {
|
||||
$fields[] = 'Duration';
|
||||
}
|
||||
|
||||
if (!empty($fields)) {
|
||||
throw new InvalidFieldsException($fields);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function validateHeader(array $header)
|
||||
{
|
||||
$result = array_diff(self::$supportedHeader, $header);
|
||||
|
||||
return empty($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add project specific conversion logic here
|
||||
*
|
||||
* @param array $row
|
||||
*/
|
||||
private function convertRow(array &$row)
|
||||
{
|
||||
// negative durations
|
||||
if ($row['Duration'][0] === '-') {
|
||||
$row['Duration'] = substr($row['Duration'], 1);
|
||||
}
|
||||
|
||||
if (!\array_key_exists('Tags', $row)) {
|
||||
$row['Tags'] = null;
|
||||
}
|
||||
if (empty($row['Date'])) {
|
||||
$row['Date'] = '1970-01-01';
|
||||
}
|
||||
if (!\array_key_exists('Exported', $row)) {
|
||||
$row['Exported'] = false;
|
||||
}
|
||||
if (!\array_key_exists('Rate', $row)) {
|
||||
$row['Rate'] = null;
|
||||
}
|
||||
if (!\array_key_exists('Hourly rate', $row)) {
|
||||
$row['Hourly rate'] = null;
|
||||
}
|
||||
if (!\array_key_exists('Fixed rate', $row)) {
|
||||
$row['Fixed rate'] = null;
|
||||
}
|
||||
if (!empty($row['From'])) {
|
||||
$len = \strlen($row['From']);
|
||||
if ($len === 1) {
|
||||
$row['From'] = '0' . $row['From'] . ':00';
|
||||
} elseif ($len == 2) {
|
||||
$row['From'] = $row['From'] . ':00';
|
||||
}
|
||||
}
|
||||
if (!empty($row['To'])) {
|
||||
$len = \strlen($row['To']);
|
||||
if ($len === 1) {
|
||||
$row['To'] = '0' . $row['To'] . ':00';
|
||||
} elseif ($len == 2) {
|
||||
$row['To'] = $row['To'] . ':00';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ namespace App\Command;
|
||||
|
||||
use App\Constants;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
@@ -20,69 +20,42 @@ use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
|
||||
/**
|
||||
* Command used to do the basic installation steps for Kimai.
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:install')]
|
||||
final class InstallCommand extends Command
|
||||
{
|
||||
public const ERROR_PERMISSIONS = 1;
|
||||
public const ERROR_CACHE_CLEAN = 2;
|
||||
public const ERROR_CACHE_WARMUP = 4;
|
||||
public const ERROR_DATABASE = 8;
|
||||
public const ERROR_MIGRATIONS = 32;
|
||||
|
||||
/**
|
||||
* @var Connection
|
||||
*/
|
||||
private $connection;
|
||||
|
||||
public function __construct(Connection $connection)
|
||||
public function __construct(private Connection $connection, private string $kernelEnvironment)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->connection = $connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:install')
|
||||
->setDescription('Basic installation for Kimai')
|
||||
->setHelp('This command will perform the basic installation steps to get Kimai up and running.')
|
||||
->addOption('no-cache', null, InputOption::VALUE_NONE, 'Skip cache re-generation')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int|null
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Kimai installation running ...');
|
||||
|
||||
/** @var Application $application */
|
||||
$application = $this->getApplication();
|
||||
/** @var KernelInterface $kernel */
|
||||
$kernel = $application->getKernel();
|
||||
$environment = $kernel->getEnvironment();
|
||||
|
||||
// create the database, in case it is not yet existing
|
||||
try {
|
||||
$this->createDatabase($io, $input, $output);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to create database: ' . $ex->getMessage());
|
||||
|
||||
return self::ERROR_DATABASE;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// bootstrap database ONLY via doctrine migrations, so all installation will have the correct and same state
|
||||
@@ -91,22 +64,22 @@ final class InstallCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to set migration status: ' . $ex->getMessage());
|
||||
|
||||
return self::ERROR_MIGRATIONS;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$input->getOption('no-cache')) {
|
||||
// flush the cache, just to make sure ... and ignore result
|
||||
$this->rebuildCaches($environment, $io, $input, $output);
|
||||
$this->rebuildCaches($this->kernelEnvironment, $io, $input, $output);
|
||||
}
|
||||
|
||||
$io->success(
|
||||
sprintf('Congratulations! Successfully installed %s version %s', Constants::SOFTWARE, Constants::VERSION)
|
||||
);
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output)
|
||||
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io->text('Rebuilding your cache, please be patient ...');
|
||||
|
||||
@@ -116,7 +89,7 @@ final class InstallCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to clear cache: ' . $ex->getMessage());
|
||||
|
||||
return self::ERROR_CACHE_CLEAN;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$command = $this->getApplication()->find('cache:warmup');
|
||||
@@ -125,13 +98,13 @@ final class InstallCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to warmup cache: ' . $ex->getMessage());
|
||||
|
||||
return self::ERROR_CACHE_WARMUP;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function importMigrations(SymfonyStyle $io, OutputInterface $output)
|
||||
private function importMigrations(SymfonyStyle $io, OutputInterface $output): void
|
||||
{
|
||||
$command = $this->getApplication()->find('doctrine:migrations:migrate');
|
||||
$cmdInput = new ArrayInput(['--allow-no-migration' => true]);
|
||||
@@ -141,16 +114,21 @@ final class InstallCommand extends Command
|
||||
$io->writeln('');
|
||||
}
|
||||
|
||||
protected function createDatabase(SymfonyStyle $io, InputInterface $input, OutputInterface $output)
|
||||
private function createDatabase(SymfonyStyle $io, InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
if ($this->connection->isConnected()) {
|
||||
$io->note(sprintf('Database is existing and connection could be established'));
|
||||
try {
|
||||
if ($this->connection->isConnected()) {
|
||||
$io->note(sprintf('Database is existing and connection could be established'));
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->askConfirmation($input, $output, sprintf('Create the database "%s" (yes) or skip (no)?', $this->connection->getDatabase()), true)) {
|
||||
throw new \Exception('Skipped database creation, aborting installation');
|
||||
if (!$this->askConfirmation($input, $output, sprintf('Create the database "%s" (yes) or skip (no)?', $this->connection->getDatabase()), true)) {
|
||||
throw new \Exception('Skipped database creation, aborting installation');
|
||||
}
|
||||
} catch (\Exception $exception) {
|
||||
// this likely means that the database does not exist. the latest doctrine release
|
||||
// changed the behavior: in previous version this code did not throw an exception.
|
||||
}
|
||||
|
||||
$options = ['--if-not-exists' => true];
|
||||
@@ -170,7 +148,7 @@ final class InstallCommand extends Command
|
||||
* @param bool $default
|
||||
* @return bool
|
||||
*/
|
||||
private function askConfirmation(InputInterface $input, OutputInterface $output, $question, $default = false)
|
||||
private function askConfirmation(InputInterface $input, OutputInterface $output, $question, $default = false): bool
|
||||
{
|
||||
/** @var QuestionHelper $questionHelper */
|
||||
$questionHelper = $this->getHelperSet()->get('question');
|
||||
|
||||
@@ -22,6 +22,7 @@ use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use App\Utils\SearchTerm;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -33,62 +34,26 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
class InvoiceCreateCommand extends Command
|
||||
#[AsCommand(name: 'kimai:invoice:create')]
|
||||
final class InvoiceCreateCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var ServiceInvoice
|
||||
*/
|
||||
private $serviceInvoice;
|
||||
/**
|
||||
* @var CustomerRepository
|
||||
*/
|
||||
private $customerRepository;
|
||||
/**
|
||||
* @var ProjectRepository
|
||||
*/
|
||||
private $projectRepository;
|
||||
/**
|
||||
* @var InvoiceTemplateRepository
|
||||
*/
|
||||
private $invoiceTemplateRepository;
|
||||
/**
|
||||
* @var UserRepository
|
||||
*/
|
||||
private $userRepository;
|
||||
/**
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $eventDispatcher;
|
||||
/**
|
||||
* @var string|null
|
||||
*/
|
||||
private $previewDirectory;
|
||||
private $previewUniqueFile = false;
|
||||
private ?string $previewDirectory = null;
|
||||
private bool $previewUniqueFile = false;
|
||||
|
||||
public function __construct(
|
||||
ServiceInvoice $serviceInvoice,
|
||||
CustomerRepository $customerRepository,
|
||||
ProjectRepository $projectRepository,
|
||||
InvoiceTemplateRepository $invoiceTemplateRepository,
|
||||
UserRepository $userRepository,
|
||||
EventDispatcherInterface $eventDispatcher
|
||||
private ServiceInvoice $serviceInvoice,
|
||||
private CustomerRepository $customerRepository,
|
||||
private ProjectRepository $projectRepository,
|
||||
private InvoiceTemplateRepository $invoiceTemplateRepository,
|
||||
private UserRepository $userRepository,
|
||||
private EventDispatcherInterface $eventDispatcher
|
||||
) {
|
||||
$this->serviceInvoice = $serviceInvoice;
|
||||
$this->customerRepository = $customerRepository;
|
||||
$this->projectRepository = $projectRepository;
|
||||
$this->invoiceTemplateRepository = $invoiceTemplateRepository;
|
||||
$this->userRepository = $userRepository;
|
||||
$this->eventDispatcher = $eventDispatcher;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:invoice:create')
|
||||
->setDescription('Create invoices')
|
||||
->setHelp('This command allows to create invoices by several different filters.')
|
||||
->addOption('user', null, InputOption::VALUE_REQUIRED, 'The user to be used for generating the invoices')
|
||||
@@ -101,7 +66,6 @@ class InvoiceCreateCommand extends Command
|
||||
->addOption('by-project', null, InputOption::VALUE_NONE, 'If set, one invoice for each active project in the given timerange is created')
|
||||
->addOption('set-exported', null, InputOption::VALUE_NONE, 'Whether the invoice items should be marked as exported')
|
||||
->addOption('template', null, InputOption::VALUE_OPTIONAL, 'Invoice template', null)
|
||||
->addOption('template-meta', null, InputOption::VALUE_OPTIONAL, 'Fetch invoice template from a meta-field', null)
|
||||
->addOption('search', null, InputOption::VALUE_OPTIONAL, 'Search term to filter invoice entries', null)
|
||||
->addOption('exported', null, InputOption::VALUE_OPTIONAL, 'Exported filter for invoice entries (possible values: exported, all), by default only "not exported" items are fetched', null)
|
||||
->addOption('preview', null, InputOption::VALUE_OPTIONAL, 'Absolute path for a rendered preview of the invoice, which will neither be saved nor the items be marked as exported.', null)
|
||||
@@ -109,10 +73,7 @@ class InvoiceCreateCommand extends Command
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
@@ -122,16 +83,17 @@ class InvoiceCreateCommand extends Command
|
||||
if (empty($username)) {
|
||||
$io->error('You must set a "user" to create invoices');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$user = $this->userRepository->loadUserByUsername($username);
|
||||
if (null === $user) {
|
||||
try {
|
||||
$user = $this->userRepository->loadUserByIdentifier($username);
|
||||
} catch (\Exception $exception) {
|
||||
$io->error(
|
||||
sprintf('The given username "%s" could not be resolved', $username)
|
||||
);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$exportedFilter = TimesheetQuery::STATE_NOT_EXPORTED;
|
||||
@@ -150,7 +112,7 @@ class InvoiceCreateCommand extends Command
|
||||
default:
|
||||
$io->error('Unknown "exported" filter given');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$timezone = $input->getOption('timezone');
|
||||
@@ -164,7 +126,7 @@ class InvoiceCreateCommand extends Command
|
||||
if (!empty($input->getOption('start')) && empty($input->getOption('end'))) {
|
||||
$io->error('You need to supply a end date if a start date was given');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$byActiveCustomer = $input->getOption('by-customer');
|
||||
@@ -173,7 +135,7 @@ class InvoiceCreateCommand extends Command
|
||||
if ($byActiveCustomer && $byActiveProject) {
|
||||
$io->error('You cannot mix "by-customer" and "by-project"');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$customersIDs = $input->getOption('customer');
|
||||
@@ -181,13 +143,7 @@ class InvoiceCreateCommand extends Command
|
||||
if (!$byActiveCustomer && !$byActiveProject && empty($customersIDs) && empty($projectIDs)) {
|
||||
$io->error('Could not determine generation mode, you need to set one of: customer, project, by-customer, by-project');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (null === $input->getOption('template') && null === $input->getOption('template-meta')) {
|
||||
$io->error('You must either pass the "template" or "template-meta" option');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$start = $input->getOption('start');
|
||||
@@ -197,7 +153,7 @@ class InvoiceCreateCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Invalid start date given');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
if (!$start instanceof \DateTime) {
|
||||
@@ -212,7 +168,7 @@ class InvoiceCreateCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Invalid end date given');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
if (!$end instanceof \DateTime) {
|
||||
@@ -227,12 +183,12 @@ class InvoiceCreateCommand extends Command
|
||||
|
||||
$markAsExported = false;
|
||||
if ($input->getOption('preview') !== null) {
|
||||
$this->previewUniqueFile = $input->getOption('preview-unique');
|
||||
$this->previewUniqueFile = (bool) $input->getOption('preview-unique');
|
||||
$this->previewDirectory = rtrim($input->getOption('preview'), '/') . '/';
|
||||
if (!is_dir($this->previewDirectory) || !is_writable($this->previewDirectory)) {
|
||||
$io->error('Invalid preview directory given');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
} elseif ($input->getOption('set-exported')) {
|
||||
$markAsExported = true;
|
||||
@@ -245,7 +201,6 @@ class InvoiceCreateCommand extends Command
|
||||
$defaultQuery->setEnd($end);
|
||||
$defaultQuery->setCurrentUser($user);
|
||||
$defaultQuery->setSearchTerm($searchTerm);
|
||||
$defaultQuery->setMarkAsExported($markAsExported);
|
||||
$defaultQuery->setExported($exportedFilter);
|
||||
|
||||
/** @var Invoice[] $invoices */
|
||||
@@ -261,7 +216,7 @@ class InvoiceCreateCommand extends Command
|
||||
if (null === $tmp) {
|
||||
$io->error('Unknown customer ID: ' . $id);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$customers[] = $tmp;
|
||||
}
|
||||
@@ -276,7 +231,7 @@ class InvoiceCreateCommand extends Command
|
||||
if (null === $tmp) {
|
||||
$io->error('Unknown project ID: ' . $id);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$projects[] = $tmp;
|
||||
}
|
||||
@@ -290,7 +245,7 @@ class InvoiceCreateCommand extends Command
|
||||
} else {
|
||||
$io->error('Could not determine generation mode'); //-///9==8=//99/96//////-*/-*//96* <= by Ayumi
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return $this->renderInvoiceResult($input, $output, $invoices);
|
||||
@@ -312,11 +267,16 @@ class InvoiceCreateCommand extends Command
|
||||
$invoices = [];
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$customer = $project->getCustomer();
|
||||
if ($customer === null) {
|
||||
throw new \Exception('Project has no customer: ' . $project->getId());
|
||||
}
|
||||
|
||||
$query = clone $defaultQuery;
|
||||
$query->addProject($project);
|
||||
$query->addCustomer($project->getCustomer());
|
||||
$query->addCustomer($customer);
|
||||
|
||||
$tpl = $this->getTemplateForProject($input, $project);
|
||||
$tpl = $this->getTemplateForCustomer($input, $customer);
|
||||
if (null === $tpl) {
|
||||
$io->warning(sprintf('Could not find invoice template for project "%s", skipping!', $project->getName()));
|
||||
continue;
|
||||
@@ -325,9 +285,9 @@ class InvoiceCreateCommand extends Command
|
||||
|
||||
try {
|
||||
if (null !== $this->previewDirectory) {
|
||||
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($query, $this->eventDispatcher));
|
||||
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($this->serviceInvoice->createModel($query), $this->eventDispatcher));
|
||||
} else {
|
||||
$invoices[] = $this->serviceInvoice->createInvoice($query, $this->eventDispatcher);
|
||||
$invoices[] = $this->serviceInvoice->createInvoice($this->serviceInvoice->createModel($query), $this->eventDispatcher);
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(sprintf('Failed to create invoice for project "%s" with: %s', $project->getName(), $ex->getMessage()));
|
||||
@@ -396,9 +356,9 @@ class InvoiceCreateCommand extends Command
|
||||
|
||||
try {
|
||||
if (null !== $this->previewDirectory) {
|
||||
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($query, $this->eventDispatcher));
|
||||
$invoices[] = $this->saveInvoicePreview($this->serviceInvoice->renderInvoice($this->serviceInvoice->createModel($query), $this->eventDispatcher));
|
||||
} else {
|
||||
$invoices[] = $this->serviceInvoice->createInvoice($query, $this->eventDispatcher);
|
||||
$invoices[] = $this->serviceInvoice->createInvoice($this->serviceInvoice->createModel($query), $this->eventDispatcher);
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(sprintf('Failed to create invoice for customer "%s" with: %s', $customer->getName(), $ex->getMessage()));
|
||||
@@ -421,7 +381,7 @@ class InvoiceCreateCommand extends Command
|
||||
if (empty($invoices)) {
|
||||
$io->warning('No invoice was generated');
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if (null !== $this->previewDirectory) {
|
||||
@@ -437,7 +397,7 @@ class InvoiceCreateCommand extends Command
|
||||
|
||||
$table->render();
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$columns = ['ID', 'Customer', 'Total', 'Filename'];
|
||||
@@ -465,56 +425,24 @@ class InvoiceCreateCommand extends Command
|
||||
|
||||
$table->render();
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function getTemplateForCustomer(InputInterface $input, Customer $customer): ?InvoiceTemplate
|
||||
{
|
||||
$template = $input->getOption('template');
|
||||
|
||||
$meta = $input->getOption('template-meta');
|
||||
if (!empty($meta)) {
|
||||
$metaField = $customer->getMetaField($meta);
|
||||
if (null !== $metaField && !empty($metaField->getValue())) {
|
||||
$template = $metaField->getValue();
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $template) {
|
||||
return null;
|
||||
return $customer->getInvoiceTemplate();
|
||||
}
|
||||
|
||||
return $this->findTemplate($template);
|
||||
}
|
||||
|
||||
private function findTemplate(string $idOrName): ?InvoiceTemplate
|
||||
{
|
||||
$tpl = $this->invoiceTemplateRepository->find($idOrName);
|
||||
$tpl = $this->invoiceTemplateRepository->find($template);
|
||||
|
||||
if (null !== $tpl) {
|
||||
return $tpl;
|
||||
}
|
||||
|
||||
return $this->invoiceTemplateRepository->findOneBy(['name' => $idOrName]);
|
||||
}
|
||||
|
||||
private function getTemplateForProject(InputInterface $input, Project $project): ?InvoiceTemplate
|
||||
{
|
||||
$template = $this->getTemplateForCustomer($input, $project->getCustomer());
|
||||
|
||||
$meta = $input->getOption('template-meta');
|
||||
if (!empty($meta)) {
|
||||
$metaField = $project->getMetaField($meta);
|
||||
if (null !== $metaField && !empty($metaField->getValue())) {
|
||||
$template = $metaField->getValue();
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->findTemplate($template);
|
||||
return $this->invoiceTemplateRepository->findOneBy(['name' => $template]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\Plugin\PluginManager;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -18,35 +19,23 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
/**
|
||||
* Command used to fetch plugin information.
|
||||
*/
|
||||
class PluginCommand extends Command
|
||||
#[AsCommand(name: 'kimai:plugins')]
|
||||
final class PluginCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var PluginManager
|
||||
*/
|
||||
private $plugins;
|
||||
|
||||
public function __construct(PluginManager $plugins)
|
||||
public function __construct(private PluginManager $plugins)
|
||||
{
|
||||
$this->plugins = $plugins;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:plugins')
|
||||
->setDescription('Receive plugin information')
|
||||
->setHelp('This command prints detailed plugin information.')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
@@ -54,7 +43,7 @@ class PluginCommand extends Command
|
||||
if (empty($plugins)) {
|
||||
$io->warning('No plugins installed');
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
@@ -70,6 +59,6 @@ class PluginCommand extends Command
|
||||
}
|
||||
$io->table(['Name', 'Version', 'Requires', 'Directory'], $rows);
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,37 +11,31 @@ namespace App\Command;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\User\UserService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class PromoteUserCommand extends AbstractRoleCommand
|
||||
#[AsCommand(name: 'kimai:user:promote')]
|
||||
final class PromoteUserCommand extends AbstractRoleCommand
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
parent::configure();
|
||||
|
||||
$this
|
||||
->setName('kimai:user:promote')
|
||||
->setAliases(['fos:user:promote'])
|
||||
->setDescription('Promotes a user by adding a role')
|
||||
->setHelp(
|
||||
<<<'EOT'
|
||||
The <info>kimai:user:promote</info> command promotes a user by adding a role
|
||||
The <info>kimai:user:promote</info> command promotes a user by adding a role
|
||||
|
||||
<info>php %command.full_name% susan_super ROLE_TEAMLEAD</info>
|
||||
<info>php %command.full_name% --super susan_super</info>
|
||||
EOT
|
||||
<info>php %command.full_name% susan_super ROLE_TEAMLEAD</info>
|
||||
<info>php %command.full_name% --super susan_super</info>
|
||||
EOT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role)
|
||||
{
|
||||
$username = $user->getUsername();
|
||||
$username = $user->getUserIdentifier();
|
||||
if ($super) {
|
||||
if (!$user->isSuperAdmin()) {
|
||||
$user->setSuperAdmin(true);
|
||||
|
||||
133
src/Command/RegenerateLocalesCommand.php
Normal file
133
src/Command/RegenerateLocalesCommand.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Intl\Locales;
|
||||
|
||||
/**
|
||||
* Command used to create the locale definition.
|
||||
*
|
||||
* We do NOT calculate that on every system again, because we want to make sure that we have the same
|
||||
* settings in every environment. Some environments (e.g. Github-Actions) have diverging settings from
|
||||
* the local system.
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:reset:locales')]
|
||||
final class RegenerateLocalesCommand extends Command
|
||||
{
|
||||
private string $defaultDate = 'dd.MM.y';
|
||||
private string $defaultTime = 'HH:mm';
|
||||
private array $rtlLocales = [
|
||||
'ar' => true,
|
||||
'fa' => true,
|
||||
'he' => true,
|
||||
];
|
||||
|
||||
public function __construct(private LocaleService $localeService, private string $projectDirectory, private string $kernelEnvironment)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->kernelEnvironment !== 'prod';
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Regenerate the locale definition file');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$locales = $this->localeService->getAllLocales();
|
||||
|
||||
// detect all registered locales and allow to choose them as well, so people get to
|
||||
// choose the language for translation with the correct format of their location
|
||||
/*
|
||||
$secondLevel = [];
|
||||
foreach (Locales::getLocales() as $locale) {
|
||||
if (substr_count($locale, '_') === 1) {
|
||||
$baseLocale = substr($locale, 0, strpos($locale, '_'));
|
||||
if (in_array($baseLocale, $locales)) {
|
||||
$subLocale = substr($locale, strpos($locale, '_') + 1);
|
||||
if (!is_numeric($subLocale)) {
|
||||
$secondLevel[] = $locale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$locales = array_merge($locales, $secondLevel);
|
||||
*/
|
||||
|
||||
$appLocales = [];
|
||||
$defaults = [
|
||||
'date' => $this->defaultDate,
|
||||
'time' => $this->defaultTime,
|
||||
'rtl' => false,
|
||||
];
|
||||
|
||||
// make sure all allowed locales are registered
|
||||
foreach ($locales as $locale) {
|
||||
if (!Locales::exists($locale)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$appLocales[$locale] = $defaults;
|
||||
}
|
||||
|
||||
// make sure all keys are registered for every locale
|
||||
foreach ($appLocales as $locale => $settings) {
|
||||
// these are completely new since v2
|
||||
// calculate everything with IntlFormatter
|
||||
$shortDate = new \IntlDateFormatter($locale, \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE);
|
||||
$shortTime = new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::SHORT);
|
||||
|
||||
$settings['date'] = $shortDate->getPattern();
|
||||
$settings['time'] = $shortTime->getPattern();
|
||||
|
||||
// make sure that sub-locales of a RTL language are also flagged as RTL
|
||||
$rtlLocale = $locale;
|
||||
if (substr_count($rtlLocale, '_') === 1) {
|
||||
$rtlLocale = substr($rtlLocale, 0, strpos($rtlLocale, '_'));
|
||||
}
|
||||
|
||||
if (\array_key_exists($rtlLocale, $this->rtlLocales)) {
|
||||
$settings['rtl'] = $this->rtlLocales[$rtlLocale];
|
||||
}
|
||||
|
||||
// pre-fill all formats with the default locale settings
|
||||
$appLocales[$locale] = $settings;
|
||||
}
|
||||
|
||||
ksort($appLocales);
|
||||
|
||||
$filename = 'config/locales.php';
|
||||
$targetFile = $this->projectDirectory . DIRECTORY_SEPARATOR . $filename;
|
||||
|
||||
$content = '<?php return ' . var_export($appLocales, true) . ';';
|
||||
$content = str_replace('array (', '[', $content);
|
||||
$content = str_replace(')', ']', $content);
|
||||
|
||||
file_put_contents($targetFile, $content);
|
||||
|
||||
$io->success('Created new locale definition at: ' . $filename);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -9,26 +9,26 @@
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\StringInput;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
|
||||
/**
|
||||
* Command used to update a Kimai installation.
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:reload')]
|
||||
final class ReloadCommand extends Command
|
||||
{
|
||||
public const ERROR_CACHE_CLEAN = 2;
|
||||
public const ERROR_CACHE_WARMUP = 4;
|
||||
public const ERROR_LINT_CONFIG = 8;
|
||||
public const ERROR_LINT_TRANSLATIONS = 16;
|
||||
public function __construct(private string $projectDirectory, private string $kernelEnvironment)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base directory to the Kimai installation.
|
||||
@@ -37,30 +37,18 @@ final class ReloadCommand extends Command
|
||||
*/
|
||||
protected function getRootDirectory(): string
|
||||
{
|
||||
/** @var Application $application */
|
||||
$application = $this->getApplication();
|
||||
|
||||
return $application->getKernel()->getProjectDir();
|
||||
return $this->projectDirectory;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:reload')
|
||||
->setDescription('Reload Kimai caches')
|
||||
->setHelp('This command will validate the configurations and translations and then clear and rebuild the application cache.')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int|null
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
@@ -82,7 +70,7 @@ final class ReloadCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return self::ERROR_LINT_CONFIG;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -97,14 +85,10 @@ final class ReloadCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return self::ERROR_LINT_TRANSLATIONS;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
/** @var Application $application */
|
||||
$application = $this->getApplication();
|
||||
/** @var KernelInterface $kernel */
|
||||
$kernel = $application->getKernel();
|
||||
$environment = $kernel->getEnvironment();
|
||||
$environment = $this->kernelEnvironment;
|
||||
|
||||
// flush the cache, in case values from the database are cached
|
||||
$cacheResult = $this->rebuildCaches($environment, $io, $input, $output);
|
||||
@@ -122,17 +106,17 @@ final class ReloadCommand extends Command
|
||||
]
|
||||
);
|
||||
|
||||
return $cacheResult;
|
||||
return (int) $cacheResult;
|
||||
}
|
||||
|
||||
$io->success(
|
||||
sprintf('Kimai config was reloaded')
|
||||
);
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output)
|
||||
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$io->text('Rebuilding your cache, please be patient ...');
|
||||
|
||||
@@ -144,7 +128,7 @@ final class ReloadCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return self::ERROR_CACHE_CLEAN;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$command = $this->getApplication()->find('cache:warmup');
|
||||
@@ -155,9 +139,9 @@ final class ReloadCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return self::ERROR_CACHE_WARMUP;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -21,11 +21,12 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
* This is one of the cases where I don't feel like it is necessary to add tests, so lets "cheat" with:
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class ResetDevelopmentCommand extends AbstractResetCommand
|
||||
#[AsCommand(name: 'kimai:reset:dev', description: 'Resets the "development" environment')]
|
||||
final class ResetDevelopmentCommand extends AbstractResetCommand
|
||||
{
|
||||
protected function getEnvName(): string
|
||||
public function __construct(string $kernelEnvironment)
|
||||
{
|
||||
return 'dev';
|
||||
parent::__construct($kernelEnvironment);
|
||||
}
|
||||
|
||||
protected function loadData(InputInterface $input, OutputInterface $output): void
|
||||
|
||||
@@ -17,6 +17,8 @@ use App\Entity\User;
|
||||
use App\Entity\UserPreference;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -29,19 +31,12 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
* This is one of the cases where I don't feel like it is necessary to add tests, so lets "cheat" with:
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class ResetTestCommand extends AbstractResetCommand
|
||||
#[AsCommand(name: 'kimai:reset:test', description: 'Resets the "test" environment')]
|
||||
final class ResetTestCommand extends AbstractResetCommand
|
||||
{
|
||||
private $entityManager;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
public function __construct(private EntityManagerInterface $entityManager, string $kernelEnvironment)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->entityManager = $entityManager;
|
||||
}
|
||||
|
||||
protected function getEnvName(): string
|
||||
{
|
||||
return 'test';
|
||||
parent::__construct($kernelEnvironment);
|
||||
}
|
||||
|
||||
protected function loadData(InputInterface $input, OutputInterface $output): void
|
||||
@@ -54,13 +49,12 @@ class ResetTestCommand extends AbstractResetCommand
|
||||
$activity->setBudget(1000);
|
||||
$this->entityManager->persist($activity);
|
||||
|
||||
$customer = new Customer();
|
||||
$customer = new Customer('Test');
|
||||
$customer->setNumber('1');
|
||||
$customer->setComment('Test comment');
|
||||
$customer->setContact('Test');
|
||||
$customer->setAddress('Test');
|
||||
$customer->setCompany('Test');
|
||||
$customer->setName('Test');
|
||||
$customer->setCountry('DE');
|
||||
$customer->setCurrency('EUR');
|
||||
$customer->setPhone('111');
|
||||
@@ -83,19 +77,174 @@ class ResetTestCommand extends AbstractResetCommand
|
||||
|
||||
$users = [
|
||||
// 0=id, 1=hourly rate, 2=Alias, 3=registration date, 4=title, 5=avatar, 6=enabled, 7=password, 8=roles, 9=username, 10=username canonical, 11=email, 12=email canonical, 13=salt, 14=last login, 15=confirmation token, 16=password requested at, 17=api_token
|
||||
[1, 53, 'Clara Haynes', '2018-02-06 23:28:57', 'CFO', 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y', 1, '$2y$04$kKBYJ8sKCOhhakCjm9sCp.TQdwLTS1FPkPiWn2KBmaCA7xFL0NA42', ['ROLE_CUSTOMER'], 'clara_customer', 'clara_customer', 'clara_customer@example.com', 'clara_customer@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
|
||||
[2, 82, 'John Doe', '2018-02-06 23:28:57', 'Developer', 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', 1, '$2y$04$36P/xyhP6FbnfFYbXy7V0.ioSe8HjMlJQFYnlIzz2T6Agfi8ob6jK', [], 'john_user', 'john_user', 'john_user@example.com', 'john_user@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
|
||||
[3, 35, 'Chris Deactive', '2018-02-06 23:28:57', 'Developer (left company)', 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', 0, '$2y$04$MLtQBZ9JLzWu1Y01QnNjsuoLm8qC9XRkpUywf6DIbpd9OAL1mEcCi', [], 'chris_user', 'chris_user', 'chris_user@example.com', 'chris_user@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
|
||||
[4, 35, 'Tony Maier', '2018-02-06 23:28:57', 'Head of Development', 'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg', 1, '$2y$04$rqxiiExfUVzIYRVL2x4JJumQWNPIG6PazXwrSJm/VQFEesR08Uj5i', ['ROLE_TEAMLEAD'], 'tony_teamlead', 'tony_teamlead', 'tony_teamlead@example.com', 'tony_teamlead@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
|
||||
[5, 81, 'Anna Smith', '2018-02-06 23:28:57', 'Administrator', null, 1, '$2y$04$ct/rVb.naDzYZECnvfTJ2uns/zPHv8.8KcunhTjYFwWQeg1dywI8G', ['ROLE_ADMIN'], 'anna_admin', 'anna_admin', 'anna_admin@example.com', 'anna_admin@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
|
||||
[6, 46, null, '2018-02-06 23:28:57', 'Super Administrator', '/bundles/avanzuadmintheme/img/avatar.png', 1, '$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2', ['ROLE_SUPER_ADMIN'], 'susan_super', 'susan_super', 'susan_super@example.com', 'susan_super@example.com', null, '2020-04-14 09:50:38', null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
|
||||
[7, null, 'Test User 1', null, 'Quality Tester 1', null, 1, '$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2', [], 'test_user_1', 'test_user_1', 'test_user_1@example.com', 'test_user_1@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
|
||||
[8, null, 'Test User 2', null, 'Quality Tester 2', null, 1, '$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2', [], 'test_user_2', 'test_user_2', 'test_user_2@example.com', 'test_user_2@example.com', null, null, null, null, '$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'],
|
||||
[
|
||||
1,
|
||||
53,
|
||||
'Clara Haynes',
|
||||
'2018-02-06 23:28:57',
|
||||
'CFO',
|
||||
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y',
|
||||
1,
|
||||
'$2y$04$kKBYJ8sKCOhhakCjm9sCp.TQdwLTS1FPkPiWn2KBmaCA7xFL0NA42',
|
||||
['ROLE_CUSTOMER'],
|
||||
'clara_customer',
|
||||
'clara_customer',
|
||||
'clara_customer@example.com',
|
||||
'clara_customer@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
],
|
||||
[
|
||||
2,
|
||||
82,
|
||||
'John Doe',
|
||||
'2018-02-06 23:28:57',
|
||||
'Developer',
|
||||
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y',
|
||||
1,
|
||||
'$2y$04$36P/xyhP6FbnfFYbXy7V0.ioSe8HjMlJQFYnlIzz2T6Agfi8ob6jK',
|
||||
[],
|
||||
'john_user',
|
||||
'john_user',
|
||||
'john_user@example.com',
|
||||
'john_user@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
],
|
||||
[
|
||||
3,
|
||||
35,
|
||||
'Chris Deactive',
|
||||
'2018-02-06 23:28:57',
|
||||
'Developer (left company)',
|
||||
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y',
|
||||
0,
|
||||
'$2y$04$MLtQBZ9JLzWu1Y01QnNjsuoLm8qC9XRkpUywf6DIbpd9OAL1mEcCi',
|
||||
[],
|
||||
'chris_user',
|
||||
'chris_user',
|
||||
'chris_user@example.com',
|
||||
'chris_user@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
],
|
||||
[
|
||||
4,
|
||||
35,
|
||||
'Tony Maier',
|
||||
'2018-02-06 23:28:57',
|
||||
'Head of Development',
|
||||
'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg',
|
||||
1,
|
||||
'$2y$04$rqxiiExfUVzIYRVL2x4JJumQWNPIG6PazXwrSJm/VQFEesR08Uj5i',
|
||||
['ROLE_TEAMLEAD'],
|
||||
'tony_teamlead',
|
||||
'tony_teamlead',
|
||||
'tony_teamlead@example.com',
|
||||
'tony_teamlead@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
],
|
||||
[
|
||||
5,
|
||||
81,
|
||||
'Anna Smith',
|
||||
'2018-02-06 23:28:57',
|
||||
'Administrator',
|
||||
null,
|
||||
1,
|
||||
'$2y$04$ct/rVb.naDzYZECnvfTJ2uns/zPHv8.8KcunhTjYFwWQeg1dywI8G',
|
||||
['ROLE_ADMIN'],
|
||||
'anna_admin',
|
||||
'anna_admin',
|
||||
'anna_admin@example.com',
|
||||
'anna_admin@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
],
|
||||
[
|
||||
6,
|
||||
46,
|
||||
null,
|
||||
'2018-02-06 23:28:57',
|
||||
'Super Administrator',
|
||||
'/bundles/avanzuadmintheme/img/avatar.png',
|
||||
1,
|
||||
'$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2',
|
||||
['ROLE_SUPER_ADMIN'],
|
||||
'susan_super',
|
||||
'susan_super',
|
||||
'susan_super@example.com',
|
||||
'susan_super@example.com',
|
||||
null,
|
||||
'2020-04-14 09:50:38',
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
],
|
||||
[
|
||||
7,
|
||||
null,
|
||||
'Test User 1',
|
||||
null,
|
||||
'Quality Tester 1',
|
||||
null,
|
||||
1,
|
||||
'$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2',
|
||||
[],
|
||||
'test_user_1',
|
||||
'test_user_1',
|
||||
'test_user_1@example.com',
|
||||
'test_user_1@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
],
|
||||
[
|
||||
8,
|
||||
null,
|
||||
'Test User 2',
|
||||
null,
|
||||
'Quality Tester 2',
|
||||
null,
|
||||
1,
|
||||
'$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2',
|
||||
[],
|
||||
'test_user_2',
|
||||
'test_user_2',
|
||||
'test_user_2@example.com',
|
||||
'test_user_2@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO'
|
||||
],
|
||||
];
|
||||
|
||||
$userEntities = [];
|
||||
foreach ($users as $userConf) {
|
||||
$user = new User();
|
||||
foreach (User::WIZARDS as $wizard) {
|
||||
$user->setWizardAsSeen($wizard);
|
||||
}
|
||||
if ($userConf[1] !== null) {
|
||||
$user->setPreferenceValue(UserPreference::HOURLY_RATE, $userConf[1]);
|
||||
}
|
||||
@@ -120,7 +269,7 @@ class ResetTestCommand extends AbstractResetCommand
|
||||
} else {
|
||||
$user->setRoles(['ROLE_USER']);
|
||||
}
|
||||
$user->setUsername($userConf[9]);
|
||||
$user->setUserIdentifier($userConf[9]);
|
||||
if ($userConf[10] !== null) {
|
||||
// removed field: UsernameCanonical
|
||||
}
|
||||
@@ -138,8 +287,7 @@ class ResetTestCommand extends AbstractResetCommand
|
||||
$userEntities[] = $user;
|
||||
}
|
||||
|
||||
$team = new Team();
|
||||
$team->setName('Test team');
|
||||
$team = new Team('Test team');
|
||||
$team->addTeamlead($userEntities[6]);
|
||||
$team->addUser($userEntities[7]);
|
||||
$this->entityManager->persist($team);
|
||||
@@ -155,9 +303,9 @@ class ResetTestCommand extends AbstractResetCommand
|
||||
} catch (Exception $ex) {
|
||||
$io->error('Failed to drop database schema: ' . $ex->getMessage());
|
||||
|
||||
return 2;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\Timesheet\TimesheetService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -18,23 +19,20 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
/**
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class TimesheetStopAllCommand extends Command
|
||||
#[AsCommand(name: 'kimai:timesheet:stop-all')]
|
||||
final class TimesheetStopAllCommand extends Command
|
||||
{
|
||||
private $timesheetService;
|
||||
|
||||
public function __construct(TimesheetService $timesheetService)
|
||||
public function __construct(private TimesheetService $timesheetService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->timesheetService = $timesheetService;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName('kimai:timesheet:stop-all');
|
||||
$this->setDescription('Stop all running timesheets immediately');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$amount = $this->timesheetService->stopAll();
|
||||
|
||||
@@ -43,6 +41,6 @@ class TimesheetStopAllCommand extends Command
|
||||
$io->success(sprintf('Stopped %s timesheet records.', $amount));
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use App\Kernel;
|
||||
use App\Utils\LanguageService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -24,34 +25,24 @@ use Symfony\Component\HttpClient\HttpClient;
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class TranslationCommand extends Command
|
||||
#[AsCommand(name: 'kimai:translations')]
|
||||
final class TranslationCommand extends Command
|
||||
{
|
||||
private $projectDirectory;
|
||||
private $environment;
|
||||
private $languageService;
|
||||
|
||||
public function __construct(string $projectDirectory, string $kernelEnvironment, LanguageService $languageService)
|
||||
public function __construct(private string $projectDirectory, private string $kernelEnvironment, private LocaleService $localeService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->projectDirectory = $projectDirectory;
|
||||
$this->environment = $kernelEnvironment;
|
||||
$this->languageService = $languageService;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:translations')
|
||||
->setDescription('Translation adjustments')
|
||||
->addOption('resname', null, InputOption::VALUE_NONE, 'Fix the resname vs. id attribute')
|
||||
->addOption('duplicates', null, InputOption::VALUE_NONE, 'Find duplicate translation keys')
|
||||
->addOption('delete-resname', null, InputOption::VALUE_REQUIRED, 'Deletes the translation by resname')
|
||||
->addOption('extension', null, InputOption::VALUE_NONE, 'Find translation files with wrong extensions')
|
||||
->addOption('fill-empty', null, InputOption::VALUE_NONE, 'Pre-fills empty translations with the english version')
|
||||
->addOption('delete-empty', null, InputOption::VALUE_NONE, 'Delete all empty kyes and files which have no translated key at all')
|
||||
->addOption('delete-empty', null, InputOption::VALUE_NONE, 'Delete all empty keys and files which have no translated key at all')
|
||||
// DEEPL TRANSLATION FEATURE - UNTESTED
|
||||
->addOption('translate-locale', null, InputOption::VALUE_REQUIRED, 'Translate into the given locale with Deepl')
|
||||
// @see https://www.deepl.com/de/pro#developer
|
||||
@@ -61,18 +52,16 @@ class TranslationCommand extends Command
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->environment !== 'prod';
|
||||
return $this->kernelEnvironment !== 'prod';
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$bases = [
|
||||
'core' => $this->projectDirectory . '/translations/*.xlf',
|
||||
'core_xliff' => $this->projectDirectory . '/translations/*.xliff',
|
||||
'plugins' => $this->projectDirectory . Kernel::PLUGIN_DIRECTORY . '/*/Resources/translations/*.xlf',
|
||||
'plugins_xliff' => $this->projectDirectory . Kernel::PLUGIN_DIRECTORY . '/*/Resources/translations/*.xliff',
|
||||
];
|
||||
|
||||
if ($input->getOption('delete-resname')) {
|
||||
@@ -123,7 +112,7 @@ class TranslationCommand extends Command
|
||||
if (!file_exists($fromLocaleName)) {
|
||||
$io->error('Could not find translation file: ' . $fromLocaleName);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$translations[$fromLocale][$name] = $this->getTranslations($fromLocaleName);
|
||||
}
|
||||
@@ -200,13 +189,13 @@ class TranslationCommand extends Command
|
||||
if ($locale !== null && $deepl === null) {
|
||||
$io->error('Missing "DeepL API Free" auth-key');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($locale === null && $deepl !== null) {
|
||||
$io->error('Missing translation locale');
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($locale !== null && $deepl !== null) {
|
||||
@@ -227,16 +216,16 @@ class TranslationCommand extends Command
|
||||
];
|
||||
|
||||
$locale = strtolower($locale);
|
||||
if (!$this->languageService->isKnownLanguage($locale)) {
|
||||
if (!$this->localeService->isKnownLocale($locale)) {
|
||||
$io->error('Unknown locale given: ' . $locale);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!\array_key_exists($locale, $deeplySupportedLanguages)) {
|
||||
$io->error('Locale not supported by Deeply: ' . $locale);
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$allKeys = 0;
|
||||
@@ -307,7 +296,7 @@ class TranslationCommand extends Command
|
||||
} catch (\Exception $exception) {
|
||||
$io->error($exception->getMessage());
|
||||
|
||||
return 1;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$json = json_decode($rawResponseData->getContent(), true);
|
||||
@@ -323,7 +312,7 @@ class TranslationCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function getTranslations(string $file): array
|
||||
@@ -364,7 +353,7 @@ class TranslationCommand extends Command
|
||||
$xmlDocument->formatOutput = true;
|
||||
$xmlDocument->loadXML($xml->asXML());
|
||||
|
||||
$xpath = new \DOMXpath($xmlDocument);
|
||||
$xpath = new \DOMXPath($xmlDocument);
|
||||
$xpath->registerNamespace('ns', $xmlDocument->documentElement->namespaceURI);
|
||||
|
||||
$xmlContent = '';
|
||||
@@ -379,7 +368,7 @@ class TranslationCommand extends Command
|
||||
}
|
||||
|
||||
$fragment = $xmlDocument->createDocumentFragment();
|
||||
$fragment->appendXml('<body>' . $xmlContent . '</body>');
|
||||
$fragment->appendXML('<body>' . $xmlContent . '</body>');
|
||||
|
||||
/** @var \DOMElement $element */
|
||||
$element = $xpath->evaluate('/ns:xliff/ns:file')->item(0);
|
||||
|
||||
@@ -11,63 +11,39 @@ namespace App\Command;
|
||||
|
||||
use App\Constants;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
|
||||
/**
|
||||
* Command used to update a Kimai installation.
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:update')]
|
||||
final class UpdateCommand extends Command
|
||||
{
|
||||
public const ERROR_CACHE_CLEAN = 2;
|
||||
public const ERROR_CACHE_WARMUP = 4;
|
||||
public const ERROR_DATABASE = 8;
|
||||
public const ERROR_MIGRATIONS = 32;
|
||||
|
||||
/**
|
||||
* @var Connection
|
||||
*/
|
||||
private $connection;
|
||||
|
||||
public function __construct(Connection $connection)
|
||||
public function __construct(private Connection $connection, private string $kernelEnvironment)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->connection = $connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:update')
|
||||
->setDescription('Update your Kimai installation')
|
||||
->setHelp('This command will execute all required steps to update your Kimai installation.')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @return int|null
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Kimai updates running ...');
|
||||
|
||||
/** @var Application $application */
|
||||
$application = $this->getApplication();
|
||||
/** @var KernelInterface $kernel */
|
||||
$kernel = $application->getKernel();
|
||||
$environment = $kernel->getEnvironment();
|
||||
$environment = $this->kernelEnvironment;
|
||||
|
||||
// make sure database is available, Kimai running and installed
|
||||
try {
|
||||
@@ -77,21 +53,21 @@ final class UpdateCommand extends Command
|
||||
);
|
||||
}
|
||||
|
||||
if (!$this->connection->getSchemaManager()->tablesExist(['kimai2_users', 'kimai2_timesheet'])) {
|
||||
if (!$this->connection->createSchemaManager()->tablesExist(['kimai2_users', 'kimai2_timesheet'])) {
|
||||
$io->error('Tables missing. Did you run the installer already?');
|
||||
|
||||
return self::ERROR_DATABASE;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$this->connection->getSchemaManager()->tablesExist(['migration_versions'])) {
|
||||
if (!$this->connection->createSchemaManager()->tablesExist(['migration_versions'])) {
|
||||
$io->error('Unknown migration status, aborting database update');
|
||||
|
||||
return self::ERROR_DATABASE;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to validate database: ' . $ex->getMessage());
|
||||
|
||||
return self::ERROR_DATABASE;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// execute latest doctrine migrations
|
||||
@@ -107,13 +83,13 @@ final class UpdateCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return self::ERROR_MIGRATIONS;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// flush the cache, in case values from the database are cached
|
||||
$cacheResult = $this->rebuildCaches($environment, $io, $input, $output);
|
||||
|
||||
if ($cacheResult !== 0) {
|
||||
if ($cacheResult !== Command::SUCCESS) {
|
||||
$io->warning(
|
||||
[
|
||||
sprintf('Updated %s to version %s but the cache could not be rebuilt.', Constants::SOFTWARE, Constants::VERSION),
|
||||
@@ -128,10 +104,10 @@ final class UpdateCommand extends Command
|
||||
);
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
protected function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output)
|
||||
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io->text('Rebuilding your cache, please be patient ...');
|
||||
|
||||
@@ -143,7 +119,7 @@ final class UpdateCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return self::ERROR_CACHE_CLEAN;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$command = $this->getApplication()->find('cache:warmup');
|
||||
@@ -154,9 +130,9 @@ final class UpdateCommand extends Command
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return self::ERROR_CACHE_WARMUP;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,77 +10,44 @@
|
||||
namespace App\Command;
|
||||
|
||||
use App\Constants;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Command used to fetch Kimai version information.
|
||||
*/
|
||||
class VersionCommand extends Command
|
||||
#[AsCommand(name: 'kimai:version')]
|
||||
final class VersionCommand extends Command
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('kimai:version')
|
||||
->setDescription('Receive version information')
|
||||
->setHelp('This command allows you to fetch various version information about Kimai.')
|
||||
->addOption('short', null, InputOption::VALUE_NONE, 'Display the version only')
|
||||
->addOption('number', null, InputOption::VALUE_NONE, 'Display the version identifier only only')
|
||||
// @deprecated since 1.14.1
|
||||
->addOption('name', null, InputOption::VALUE_NONE, 'DEPRECATED: Display the major release name')
|
||||
->addOption('candidate', null, InputOption::VALUE_NONE, 'DEPRECATED: Display the current version candidate (e.g. "stable" or "dev")')
|
||||
->addOption('semver', null, InputOption::VALUE_NONE, 'DEPRECATED: Semantical versioning (SEMVER) compatible version string')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
if ($input->getOption('semver')) {
|
||||
@trigger_error('bin/console kimai:version --semver is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
|
||||
$io->writeln(Constants::VERSION . '-' . Constants::STATUS);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($input->getOption('short')) {
|
||||
$io->writeln(Constants::VERSION);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($input->getOption('name')) {
|
||||
@trigger_error('bin/console kimai:version --name is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
|
||||
$io->writeln(Constants::NAME);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($input->getOption('candidate')) {
|
||||
@trigger_error('bin/console kimai:version --candidate is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
|
||||
$io->writeln(Constants::STATUS);
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($input->getOption('number')) {
|
||||
$io->writeln((string) Constants::VERSION_ID);
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->writeln(sprintf('%s <info>%s</info> by Kevin Papst and contributors.', Constants::SOFTWARE, Constants::VERSION));
|
||||
$io->writeln(sprintf('%s <info>%s</info> by Kevin Papst.', Constants::SOFTWARE, Constants::VERSION));
|
||||
|
||||
return 0;
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user