diff --git a/src/Command/ExportCreateCommand.php b/src/Command/ExportCreateCommand.php new file mode 100644 index 00000000..73ffa833 --- /dev/null +++ b/src/Command/ExportCreateCommand.php @@ -0,0 +1,324 @@ +serviceExport = $serviceExport; + $this->customerRepository = $customerRepository; + $this->projectRepository = $projectRepository; + $this->teamRepository = $teamRepository; + $this->userRepository = $userRepository; + $this->translator = $translator; + $this->mailer = $mailer; + parent::__construct(); + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this + ->setName('kimai:export:create') + ->setDescription('Create exports') + ->setHelp('Create exports by several different filters and sent them via email.') + ->addOption('start', null, InputOption::VALUE_OPTIONAL, 'Start date (format: 2020-01-01, default: start of the month)', null) + ->addOption('end', null, InputOption::VALUE_OPTIONAL, 'End date (format: 2020-01-31, default: end of the month)', null) + ->addOption('timezone', null, InputOption::VALUE_OPTIONAL, 'Timezone for start and end date query (fallback: server timezone)', null) + ->addOption('locale', null, InputOption::VALUE_REQUIRED, 'The locale to use', 'en') + ->addOption('customer', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Customer IDs to filter', null) + ->addOption('project', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Project IDs to filter', null) + ->addOption('team', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Team IDs to filter', null) + ->addOption('user', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'User IDs to filter', null) + ->addOption('set-exported', null, InputOption::VALUE_NONE, 'Whether the included items should be marked as exported (default: false)') + ->addOption('template', null, InputOption::VALUE_REQUIRED, 'Export template', null) + ->addOption('exported', null, InputOption::VALUE_OPTIONAL, 'Exported filter for export entries. By default only "not exported" items are fetched (possible values: exported, all)', null) + ->addOption('directory', null, InputOption::VALUE_OPTIONAL, 'Absolute path for the rendered export documents (uses system tmp dir by default)', null) + ->addOption('email', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Email address(es) for the recipients (email will be sent out with attached file, generated exports will be removed afterwards)', null) + ->addOption('subject', null, InputOption::VALUE_OPTIONAL, 'Email subject (needs to be set if "email" is configured)', null) + ->addOption('body', null, InputOption::VALUE_OPTIONAL, 'Body of the email (needs to be set if "email" is configured)', null) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $exportedFilter = TimesheetQuery::STATE_NOT_EXPORTED; + switch ($input->getOption('exported')) { + case null: + break; + + case 'all': + $exportedFilter = TimesheetQuery::STATE_ALL; + break; + + case 'exported': + $exportedFilter = TimesheetQuery::STATE_EXPORTED; + break; + + default: + $io->error('Unknown "exported" filter given'); + + return 1; + } + + $locale = $input->getOption('locale'); + \Locale::setDefault($locale); + $this->translator->setLocale($locale); + + $timezone = $input->getOption('timezone'); + if ($timezone === null) { + $timezone = date_default_timezone_get(); + } + $timezone = new \DateTimeZone($timezone); + $dateFactory = new DateTimeFactory($timezone); + + $customerIDs = $input->getOption('customer'); + $customers = []; + if (\count($customerIDs) > 0) { + $customers = $this->customerRepository->findByIds($customerIDs); + } + + $projectIDs = $input->getOption('project'); + $projects = []; + if (\count($projectIDs) > 0) { + $projects = $this->projectRepository->findByIds($projectIDs); + } + + $teamIDs = $input->getOption('team'); + $teams = []; + if (\count($teamIDs) > 0) { + $teams = $this->teamRepository->findByIds($teamIDs); + } + + $userIDs = $input->getOption('user'); + $users = []; + if (\count($userIDs) > 0) { + $users = $this->userRepository->findByIds($userIDs); + } + + $template = $input->getOption('template'); + if ($template === null) { + $io->error('You must pass the "template" option'); + + return 1; + } + $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()]; + } + $io->table(['ID'], $rows); + + return 1; + } + + $start = $input->getOption('start'); + if (!empty($start)) { + try { + $start = $dateFactory->createDateTime($start); + } catch (\Exception $ex) { + $io->error('Invalid start date given'); + + return 1; + } + } + if (!$start instanceof \DateTime) { + $start = $dateFactory->getStartOfMonth(); + } + $start->setTime(0, 0, 0); + + $end = $input->getOption('end'); + if (!empty($end)) { + try { + $end = $dateFactory->createDateTime($end); + } catch (\Exception $ex) { + $io->error('Invalid end date given'); + + return 1; + } + } + + if (empty($end)) { + $end = $dateFactory->getEndOfMonth($start); + } + + if (!$end instanceof \DateTime) { + $end = $dateFactory->getEndOfMonth(); + } + + $end->setTime(23, 59, 59); + + $directory = rtrim(sys_get_temp_dir(), '/') . '/'; + if ($input->getOption('directory') !== null) { + $directory = rtrim($input->getOption('directory'), '/') . '/'; + } + + if (!is_dir($directory) || !is_writable($directory)) { + $io->error('Invalid "directory" given: ' . $directory); + + return 1; + } + + $subject = 'Export data available'; + $body = 'Your exported data is available, please find it attached to this email.'; + + $emails = []; + $tmp = $input->getOption('email'); + if (\count($tmp) > 0) { + foreach ($tmp as $email) { + $result = filter_var($email, FILTER_VALIDATE_EMAIL); + if ($result === false) { + $io->error('Invalid "email" given: ' . $email); + + return 1; + } + $emails[] = $email; + } + } + + if ($input->getOption('subject') !== null) { + $subject = trim($input->getOption('subject')); + } + + if ($input->getOption('body') !== null) { + $body = trim($input->getOption('body')); + } + + $markAsExported = false; + if ($input->getOption('set-exported')) { + $markAsExported = true; + } + + // =============== VALIDATION END =============== + + $query = new ExportQuery(); + $query->setBegin($start); + $query->setEnd($end); + $query->setExported($exportedFilter); + $query->setCustomers($customers); + $query->setProjects($projects); + $query->setTeams($teams); + foreach ($users as $user) { + $query->addUser($user); + } + //$query->setRenderer($template); + //$query->setMarkAsExported($markAsExported); + + $io = new SymfonyStyle($input, $output); + + $entries = $this->serviceExport->getExportItems($query); + if (\count($entries) === 0) { + $io->success('No entries found, skipping'); + + return 0; + } + + $response = $renderer->render($entries, $query); + $file = $this->savePreview($response, $directory); + + if ($markAsExported) { + $this->serviceExport->setExported($entries); + } + + if (\count($emails) > 0) { + foreach ($emails as $to) { + $mail = new TemplatedEmail(); + $mail->addTo($to); + $mail->subject($subject); + $mail->htmlTemplate('emails/default.html.twig'); + $mail->context([ + 'subject' => $subject, + 'body' => $body, + ]); + $mail->attachFromPath($file); + $this->mailer->send($mail); + + $io->success('Send email with report to: ' . $to); + } + + unlink($file); + } else { + $io->success('Saved export to: ' . $file); + } + + return 0; + } + + private function savePreview(Response $response, string $directory): string + { + $filename = uniqid('invoice_'); + $directory = rtrim($directory, '/') . '/'; + + if ($response->headers->has('Content-Disposition')) { + $disposition = $response->headers->get('Content-Disposition'); + $parts = explode(';', $disposition); + foreach ($parts as $part) { + if (stripos($part, 'filename=') === false) { + continue; + } + $filename = explode('filename=', $part); + if (\count($filename) > 1) { + $filename = $filename[1]; + } + } + } + + if ($response instanceof BinaryFileResponse) { + $file = $response->getFile(); + $file->move($directory, $filename); + } else { + (new Filesystem())->dumpFile($directory . $filename, $response->getContent()); + } + + return $directory . $filename; + } +} diff --git a/src/Export/Base/AbstractSpreadsheetRenderer.php b/src/Export/Base/AbstractSpreadsheetRenderer.php index 0fbdc524..efca543d 100644 --- a/src/Export/Base/AbstractSpreadsheetRenderer.php +++ b/src/Export/Base/AbstractSpreadsheetRenderer.php @@ -33,7 +33,7 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag; -use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Security; use Symfony\Contracts\Translation\TranslatorInterface; /** @@ -71,7 +71,7 @@ abstract class AbstractSpreadsheetRenderer */ protected $dispatcher; /** - * @var AuthorizationCheckerInterface + * @var Security */ protected $voter; /** @@ -111,16 +111,21 @@ abstract class AbstractSpreadsheetRenderer 'order_number' => [], ]; - public function __construct(TranslatorInterface $translator, LocaleFormatExtensions $dateExtension, EventDispatcherInterface $dispatcher, AuthorizationCheckerInterface $voter) + public function __construct(TranslatorInterface $translator, LocaleFormatExtensions $dateExtension, EventDispatcherInterface $dispatcher, Security $security) { $this->translator = $translator; $this->dateExtension = $dateExtension; $this->dispatcher = $dispatcher; - $this->voter = $voter; + $this->voter = $security; } protected function isRenderRate(TimesheetQuery $query): bool { + if ($this->voter->getUser() === null) { + // for command line export + return true; + } + if (null !== $query->getUser()) { return $this->voter->isGranted('view_rate_own_timesheet'); } diff --git a/src/Mail/KimaiMailer.php b/src/Mail/KimaiMailer.php index 74acdde1..1a32aa58 100644 --- a/src/Mail/KimaiMailer.php +++ b/src/Mail/KimaiMailer.php @@ -15,7 +15,10 @@ use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; use Symfony\Component\Mime\RawMessage; -final class KimaiMailer implements MailerInterface +/** + * @final + */ +class KimaiMailer implements MailerInterface { /** * @var MailerInterface diff --git a/src/Repository/CustomerRepository.php b/src/Repository/CustomerRepository.php index f4203265..7886c692 100644 --- a/src/Repository/CustomerRepository.php +++ b/src/Repository/CustomerRepository.php @@ -57,6 +57,26 @@ class CustomerRepository extends EntityRepository return $customer; } + /** + * @param int[] $customerIDs + * @return Customer[] + */ + public function findByIds(array $customerIDs): array + { + $qb = $this->createQueryBuilder('c'); + $qb + ->where($qb->expr()->in('c.id', ':id')) + ->setParameter('id', $customerIDs) + ; + + $customers = $qb->getQuery()->getResult(); + + $loader = new CustomerLoader($qb->getEntityManager(), true); + $loader->loadResults($customers); + + return $customers; + } + /** * @param Customer $customer * @throws ORMException diff --git a/src/Repository/TeamRepository.php b/src/Repository/TeamRepository.php index fc5a30a6..a2dc72c6 100644 --- a/src/Repository/TeamRepository.php +++ b/src/Repository/TeamRepository.php @@ -54,6 +54,26 @@ class TeamRepository extends EntityRepository return $team; } + /** + * @param int[] $teamIds + * @return Team[] + */ + public function findByIds(array $teamIds): array + { + $qb = $this->createQueryBuilder('t'); + $qb + ->where($qb->expr()->in('t.id', ':id')) + ->setParameter('id', $teamIds) + ; + + $teams = $qb->getQuery()->getResult(); + + $loader = new TeamLoader($qb->getEntityManager()); + $loader->loadResults($teams); + + return $teams; + } + /** * @param Team $team * @throws ORMException diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index c34aa52b..b3efd743 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -69,6 +69,26 @@ class UserRepository extends EntityRepository implements UserLoaderInterface return $user; } + /** + * @param int[] $userIds + * @return User[] + */ + public function findByIds(array $userIds): array + { + $qb = $this->createQueryBuilder('u'); + $qb + ->where($qb->expr()->in('u.id', ':id')) + ->setParameter('id', $userIds) + ; + + $users = $qb->getQuery()->getResult(); + + $loader = new UserLoader($qb->getEntityManager(), true); + $loader->loadResults($users); + + return $users; + } + /** * Overwritten to fetch preferences when using the Profile controller actions. * Depends on the query, some magic mechanisms like the ParamConverter will use this method to fetch the user. diff --git a/templates/emails/default.html.twig b/templates/emails/default.html.twig new file mode 100644 index 00000000..3d75c380 --- /dev/null +++ b/templates/emails/default.html.twig @@ -0,0 +1,13 @@ +{% extends 'emails/layout.html.twig' %} + +{% block title %} + {{ subject|trans({}, 'email') }} +{% endblock %} + +{% block body %} +
{{ body|trans({}, 'email') }}
+
+
{{ 'automated_email_dont_answer'|trans({}, 'email') }}
+{% endblock %} diff --git a/tests/Command/ExportCreateCommandTest.php b/tests/Command/ExportCreateCommandTest.php new file mode 100644 index 00000000..08e9c95e --- /dev/null +++ b/tests/Command/ExportCreateCommandTest.php @@ -0,0 +1,253 @@ +clearExportFiles(); + } + + protected function setUp(): void + { + parent::setUp(); + $this->clearExportFiles(); + $this->application = $this->createApplication(); + } + + private function createApplication($mailer = null): Application + { + $kernel = self::bootKernel(); + $application = new Application($kernel); + $container = self::$container; + + $application->add(new ExportCreateCommand( + $container->get(ServiceExport::class), + $container->get(CustomerRepository::class), + $container->get(ProjectRepository::class), + $container->get(TeamRepository::class), + $container->get(UserRepository::class), + $container->get(Translator::class), + $mailer ?? $container->get(KimaiMailer::class), + )); + + return $application; + } + + /** + * Allowed option: start + * Allowed option: end + * Allowed option: timezone + * Allowed option: locale + * Allowed option: customer + * Allowed option: project + * Allowed option: team + * Allowed option: user + * Allowed option: set-exported + * Allowed option: template + * Allowed option: exported + * Allowed option: directory + * Allowed option: email + * Allowed option: subject + * Allowed option: body + * + * @param array $options + * @return CommandTester + */ + protected function createExport(array $options = []) + { + $command = $this->application->find('kimai:export:create'); + $commandTester = new CommandTester($command); + $commandTester->execute(array_merge($options, [ + 'command' => $command->getName(), + ])); + + return $commandTester; + } + + protected function assertCommandErrors(array $options = [], string $errorMessage = '') + { + $commandTester = $this->createExport($options); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('[ERROR] ' . $errorMessage, $output); + } + + protected function assertCommandResult(array $options = [], string $message = '') + { + $commandTester = $this->createExport($options); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('[OK] ' . $message, $output); + } + + public function testCreateWithUnknownExportFilter() + { + $this->assertCommandErrors(['--exported' => 'foo'], 'Unknown "exported" filter given'); + } + + public function testCreateWithUnknownTemplate() + { + $this->assertCommandErrors(['--template' => 'foo'], 'Unknown export "template", available are:'); + } + + public function testCreateWithMissingTemplate() + { + $this->assertCommandErrors([], 'You must pass the "template" option'); + } + + public function testCreateWithInvalidStart() + { + $this->assertCommandErrors(['--template' => 'csv', '--start' => '202ß-ä1-01'], 'Invalid start date given'); + } + + public function testCreateWithInvalidEnd() + { + $this->assertCommandErrors(['--template' => 'csv', '--end' => '202ß-ä1-01'], 'Invalid end date given'); + } + + public function testCreateWithInvalidDirectory() + { + $this->assertCommandErrors(['--template' => 'csv', '--directory' => '/tzuikmnbgtz/'], 'Invalid "directory" given: /tzuikmnbgtz/'); + } + + public function testCreateWithInvalidEmail() + { + $this->assertCommandErrors(['--template' => 'csv', '--email' => ['tzuikmnbgtz']], 'Invalid "email" given: tzuikmnbgtz'); + } + + public function testCreateWithInvalidEmails() + { + $this->assertCommandErrors(['--template' => 'csv', '--email' => ['foo@example.com', 'foo@1']], 'Invalid "email" given: foo@1'); + } + + public function testCreateWithMissingEntries() + { + $options = ['--set-exported' => null, '--customer' => [1], '--template' => 'csv', '--start' => '2020-01-01', '--end' => '2020-03-01']; + $commandTester = $this->createExport($options); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('[OK] No entries found, skipping', $output); + } + + protected function prepareFixtures(\DateTime $start) + { + $fixture = new CustomerFixtures(); + $fixture->setAmount(1); + $customer = $this->importFixture($fixture)[0]; + + $fixture = new ProjectFixtures(); + $fixture->setCustomers([$customer]); + $fixture->setAmount(1); + $project = $this->importFixture($fixture); + + $fixture = new TimesheetFixtures(); + $fixture->setUser($this->getUserByName(UserFixtures::USERNAME_SUPER_ADMIN)); + $fixture->setAmount(20); + $fixture->setStartDate($start); + $fixture->setProjects($project); + $this->importFixture($fixture); + + return [$customer, $project]; + } + + public function testCreateExportByCustomer() + { + $start = new \DateTime('-2 months'); + $end = new \DateTime(); + + $data = $this->prepareFixtures($start); + + $commandTester = $this->createExport(['--template' => 'csv', '--customer' => [$data[0]->getId()], '--start' => $start->format('Y-m-d'), '--end' => $end->format('Y-m-d')]); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Saved export to: ', $output); + } + + public function testCreateExportByProject() + { + $start = new \DateTime('-2 months'); + $end = new \DateTime(); + + $data = $this->prepareFixtures($start); + + $commandTester = $this->createExport(['--template' => 'csv', '--project' => [$data[1][0]->getId()], '--start' => $start->format('Y-m-d'), '--end' => $end->format('Y-m-d')]); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Saved export to: ', $output); + } + + public function testCreateExportWithEmail() + { + $start = new \DateTime('-2 months'); + $end = new \DateTime(); + + $this->prepareFixtures($start); + $options = ['--template' => 'csv', '--email' => ['foo@example.com', 'foo2@example.com'], '--start' => $start->format('Y-m-d'), '--end' => $end->format('Y-m-d')]; + + $mailer = $this->createMock(KimaiMailer::class); + $mailer->expects($this->exactly(2))->method('send'); + + $application = $this->createApplication($mailer); + $command = $application->find('kimai:export:create'); + $commandTester = new CommandTester($command); + $commandTester->execute(array_merge($options, [ + 'command' => $command->getName(), + ])); + + $output = $commandTester->getDisplay(); + $this->assertStringContainsString('Send email with report to: foo@example.com', $output); + $this->assertStringContainsString('Send email with report to: foo2@example.com', $output); + } +} diff --git a/tests/Command/InvoiceCreateCommandTest.php b/tests/Command/InvoiceCreateCommandTest.php index 10336120..4066b168 100644 --- a/tests/Command/InvoiceCreateCommandTest.php +++ b/tests/Command/InvoiceCreateCommandTest.php @@ -192,8 +192,6 @@ class InvoiceCreateCommandTest extends KernelTestCase protected function prepareFixtures(\DateTime $start) { - $em = $this->getEntityManager(); - $fixture = new CustomerFixtures(); $fixture->setAmount(1); $fixture->setCallback(function (Customer $customer) { diff --git a/tests/Export/Renderer/AbstractRendererTest.php b/tests/Export/Renderer/AbstractRendererTest.php index 60161324..20be25ee 100644 --- a/tests/Export/Renderer/AbstractRendererTest.php +++ b/tests/Export/Renderer/AbstractRendererTest.php @@ -33,7 +33,6 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Form\Extension\Core\Type\TextType; -use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Security; use Symfony\Contracts\Translation\TranslatorInterface; @@ -55,6 +54,7 @@ abstract class AbstractRendererTest extends KernelTestCase $security = $this->createMock(Security::class); $security->expects($this->any())->method('getUser')->willReturn(new User()); + $security->expects($this->any())->method('isGranted')->willReturn(true); $translator = $this->createMock(TranslatorInterface::class); $dateExtension = new LocaleFormatExtensions(new LanguageFormattings($languages), $security); @@ -62,10 +62,7 @@ abstract class AbstractRendererTest extends KernelTestCase $dispatcher = new EventDispatcher(); $dispatcher->addSubscriber(new MetaFieldColumnSubscriber()); - $authMock = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock(); - $authMock->method('isGranted')->willReturn(true); - - return new $classname($translator, $dateExtension, $dispatcher, $authMock); + return new $classname($translator, $dateExtension, $dispatcher, $security); } /** diff --git a/tests/Export/Timesheet/AbstractRendererTest.php b/tests/Export/Timesheet/AbstractRendererTest.php index 4dd96571..fa2b001f 100644 --- a/tests/Export/Timesheet/AbstractRendererTest.php +++ b/tests/Export/Timesheet/AbstractRendererTest.php @@ -32,7 +32,6 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Form\Extension\Core\Type\TextType; -use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Security\Core\Security; use Symfony\Contracts\Translation\TranslatorInterface; @@ -54,6 +53,7 @@ abstract class AbstractRendererTest extends KernelTestCase $security = $this->createMock(Security::class); $security->expects($this->any())->method('getUser')->willReturn(new User()); + $security->expects($this->any())->method('isGranted')->willReturn(true); $translator = $this->getMockBuilder(TranslatorInterface::class)->getMock(); $dateExtension = new LocaleFormatExtensions(new LanguageFormattings($languages), $security); @@ -61,10 +61,7 @@ abstract class AbstractRendererTest extends KernelTestCase $dispatcher = new EventDispatcher(); $dispatcher->addSubscriber(new MetaFieldColumnSubscriber()); - $authMock = $this->getMockBuilder(AuthorizationCheckerInterface::class)->getMock(); - $authMock->method('isGranted')->willReturn(true); - - return new $classname($translator, $dateExtension, $dispatcher, $authMock); + return new $classname($translator, $dateExtension, $dispatcher, $security); } /**