Release 2.37 (#5546)

This commit is contained in:
Kevin Papst
2025-07-04 16:43:38 +02:00
committed by GitHub
parent 06b3060fe1
commit dfec807166
52 changed files with 602 additions and 352 deletions

View File

@@ -20,19 +20,16 @@ use Symfony\Component\Console\Style\SymfonyStyle;
abstract class AbstractRoleCommand extends Command
{
public function __construct(private UserService $userService)
public function __construct(private readonly UserService $userService)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
new InputArgument('role', InputArgument::OPTIONAL, 'The role'),
new InputOption('super', null, InputOption::VALUE_NONE, 'Instead specifying role, use this to quickly add the super administrator role'),
]);
$this->addArgument('username', InputArgument::REQUIRED, 'The username');
$this->addArgument('role', InputArgument::OPTIONAL, 'The role');
$this->addOption('super', null, InputOption::VALUE_NONE, 'Instead specifying role, use this to quickly add the super administrator role');
}
protected function execute(InputInterface $input, OutputInterface $output): int

View File

@@ -26,7 +26,7 @@ abstract class AbstractUserCommand extends Command
$passwordQuestion = new Question('Please enter the password: ');
$passwordQuestion->setHidden(true);
$passwordQuestion->setHiddenFallback(false);
$passwordQuestion->setHiddenFallback(true);
$passwordQuestion->setValidator(function (?string $value) {
$password = trim($value);
if (empty($password)) {

View File

@@ -17,7 +17,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:activate')]
#[AsCommand(name: 'kimai:user:activate', description: 'Activate a user')]
final class ActivateUserCommand extends Command
{
public function __construct(private UserService $userService)
@@ -28,10 +28,7 @@ final class ActivateUserCommand extends Command
protected function configure(): void
{
$this
->setDescription('Activate a user')
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
])
->addArgument('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):

View File

@@ -18,7 +18,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:password')]
#[AsCommand(name: 'kimai:user:password', description: 'Change the password of a user')]
final class ChangePasswordCommand extends AbstractUserCommand
{
public function __construct(private UserService $userService)
@@ -29,11 +29,8 @@ final class ChangePasswordCommand extends AbstractUserCommand
protected function configure(): void
{
$this
->setDescription('Change the password of a user.')
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
new InputArgument('password', InputArgument::OPTIONAL, 'The password'),
])
->addArgument('username', InputArgument::REQUIRED, 'The username')
->addArgument('password', InputArgument::OPTIONAL, 'The password')
->setHelp(
<<<'EOT'
The <info>kimai:user:password</info> command changes the password of a user:

View File

@@ -20,7 +20,7 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:create')]
#[AsCommand(name: 'kimai:user:create', description: 'Create a new user')]
final class CreateUserCommand extends AbstractUserCommand
{
public function __construct(private UserService $userService)
@@ -33,7 +33,6 @@ final class CreateUserCommand extends AbstractUserCommand
$roles = implode(',', [User::DEFAULT_ROLE, User::ROLE_ADMIN]);
$this
->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)')
->addArgument('email', InputArgument::REQUIRED, 'Email address of the new user (must be unique)')

View File

@@ -17,7 +17,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:deactivate')]
#[AsCommand(name: 'kimai:user:deactivate', description: 'Deactivate a user')]
final class DeactivateUserCommand extends Command
{
public function __construct(private UserService $userService)
@@ -28,10 +28,7 @@ final class DeactivateUserCommand extends Command
protected function configure(): void
{
$this
->setDescription('Deactivate a user')
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
])
->addArgument('username', InputArgument::REQUIRED, 'The username')
->setHelp(
<<<'EOT'
The <info>kimai:user:deactivate</info> command deactivates a user (will not be able to log in)

View File

@@ -14,7 +14,7 @@ use App\User\UserService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:demote')]
#[AsCommand(name: 'kimai:user:demote', description: 'Demote a user by removing a role')]
final class DemoteUserCommand extends AbstractRoleCommand
{
protected function configure(): void
@@ -22,7 +22,6 @@ final class DemoteUserCommand extends AbstractRoleCommand
parent::configure();
$this
->setDescription('Demote a user by removing a role')
->setHelp(
<<<'EOT'
The <info>kimai:user:demote</info> command demotes a user by removing a role

View File

@@ -31,7 +31,7 @@ use Symfony\Component\Mailer\MailerInterface;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsCommand(name: 'kimai:export:create')]
#[AsCommand(name: 'kimai:export:create', description: 'Create exports')]
final class ExportCreateCommand extends Command
{
public function __construct(
@@ -49,7 +49,6 @@ final class ExportCreateCommand extends Command
protected function configure(): void
{
$this
->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)')
->addOption('start', null, InputOption::VALUE_OPTIONAL, 'Start date (format: 2020-01-01, default: start of the month)', null)

View File

@@ -34,7 +34,7 @@ use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
#[AsCommand(name: 'kimai:invoice:create')]
#[AsCommand(name: 'kimai:invoice:create', description: 'Create invoices')]
final class InvoiceCreateCommand extends Command
{
private ?string $previewDirectory = null;
@@ -54,7 +54,6 @@ final class InvoiceCreateCommand extends Command
protected function configure(): void
{
$this
->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')
->addOption('start', null, InputOption::VALUE_OPTIONAL, 'Start date (format: 2020-01-01, default: start of the month)', null)

View File

@@ -16,7 +16,6 @@ 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\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Mime\Email;
@@ -31,7 +30,6 @@ final class MailTestCommand extends Command
protected function configure(): void
{
$this->addArgument('to', InputArgument::REQUIRED, 'The email address to send the email to');
$this->addOption('from', null, InputOption::VALUE_OPTIONAL, 'Deprecated: uses the MAILER_FROM env variable.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
@@ -41,10 +39,6 @@ final class MailTestCommand extends Command
throw new \InvalidArgumentException('Need a non-empty "to" address');
}
if ($input->getOption('from') !== null) {
throw new \InvalidArgumentException('The "from" option is deprecated and will be ignored');
}
$message = new Email();
$message->to($to);
$message->subject('Test email - ' . Constants::SOFTWARE);

View File

@@ -14,7 +14,7 @@ use App\User\UserService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:user:promote')]
#[AsCommand(name: 'kimai:user:promote', description: 'Promotes a user by adding a role')]
final class PromoteUserCommand extends AbstractRoleCommand
{
protected function configure(): void
@@ -22,7 +22,6 @@ final class PromoteUserCommand extends AbstractRoleCommand
parent::configure();
$this
->setDescription('Promotes a user by adding a role')
->setHelp(
<<<'EOT'
The <info>kimai:user:promote</info> command promotes a user by adding a role

View File

@@ -19,19 +19,14 @@ use Symfony\Component\Console\Style\SymfonyStyle;
/**
* @codeCoverageIgnore
*/
#[AsCommand(name: 'kimai:timesheet:stop-all')]
#[AsCommand(name: 'kimai:timesheet:stop-all', description: 'Stop all running timesheets immediately')]
final class TimesheetStopAllCommand extends Command
{
public function __construct(private TimesheetService $timesheetService)
public function __construct(private readonly TimesheetService $timesheetService)
{
parent::__construct();
}
protected function configure(): void
{
$this->setDescription('Stop all running timesheets immediately');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$amount = $this->timesheetService->stopAll();

View File

@@ -25,7 +25,7 @@ use Symfony\Component\HttpClient\HttpClient;
*
* @codeCoverageIgnore
*/
#[AsCommand(name: 'kimai:translations')]
#[AsCommand(name: 'kimai:translations', description: 'Automated translation adjustments')]
final class TranslationCommand extends Command
{
public function __construct(
@@ -40,7 +40,6 @@ final class TranslationCommand extends Command
protected function configure(): void
{
$this
->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')

View File

@@ -17,17 +17,14 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'kimai:version')]
#[AsCommand(name: 'kimai:version', description: 'Show version information')]
final class VersionCommand extends Command
{
protected function configure(): void
{
$this
->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')
;
$this->setHelp('This command allows you to fetch various version information about Kimai.');
$this->addOption('short', null, InputOption::VALUE_NONE, 'Display the version only');
$this->addOption('number', null, InputOption::VALUE_NONE, 'Display the version identifier only only');
}
protected function execute(InputInterface $input, OutputInterface $output): int

View File

@@ -458,6 +458,11 @@ final class SystemConfiguration
return (bool) $this->find('timesheet.rules.break_time_active');
}
public function getExportTimeout(): int
{
return (int) $this->find('export.timeout');
}
// ========== Company configurations ==========
public function getFinancialYearStart(): ?string

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.36.1';
public const VERSION = '2.37.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 23601;
public const VERSION_ID = 23700;
/**
* The software name
*/

View File

@@ -13,6 +13,7 @@ use App\Configuration\SamlConfigurationInterface;
use App\Saml\SamlAuthFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
@@ -76,7 +77,7 @@ final class SamlController extends AbstractController
throw new \RuntimeException('SAML login failed');
}
return $this->redirect($url);
return new RedirectResponse($url);
}
#[Route(path: '/metadata', name: 'saml_metadata')]

View File

@@ -9,6 +9,7 @@
namespace App\Controller;
use App\Configuration\SystemConfiguration;
use App\Entity\ExportableItem;
use App\Entity\ExportTemplate;
use App\Export\Base\DispositionInlineInterface;
@@ -114,7 +115,7 @@ final class ExportController extends AbstractController
}
#[Route(path: '/data', name: 'export_data', methods: ['POST'])]
public function export(Request $request): Response
public function export(Request $request, SystemConfiguration $systemConfiguration): Response
{
$query = $this->getDefaultQuery();
@@ -132,6 +133,9 @@ final class ExportController extends AbstractController
throw $this->createNotFoundException('Unknown export renderer');
}
$oldMaxExecTime = \ini_get('max_execution_time');
ini_set('max_execution_time', $systemConfiguration->getExportTimeout());
// display file inline if supported and `markAsExported` is not set
if ($renderer instanceof DispositionInlineInterface && !$query->isMarkAsExported()) {
$renderer->setDispositionInline(true);
@@ -144,6 +148,8 @@ final class ExportController extends AbstractController
$this->export->setExported($entries);
}
ini_set('max_execution_time', $oldMaxExecTime);
return $response;
}

View File

@@ -41,11 +41,11 @@ use Symfony\Component\HttpFoundation\Response;
abstract class TimesheetAbstractController extends AbstractController
{
public function __construct(
protected TimesheetRepository $repository,
protected EventDispatcherInterface $dispatcher,
protected TimesheetService $service,
protected SystemConfiguration $configuration,
protected TagRepository $tagRepository
protected readonly TimesheetRepository $repository,
protected readonly EventDispatcherInterface $dispatcher,
protected readonly TimesheetService $service,
protected readonly SystemConfiguration $configuration,
protected readonly TagRepository $tagRepository
) {
}
@@ -263,7 +263,14 @@ abstract class TimesheetAbstractController extends AbstractController
$entries = $this->repository->getTimesheetResult($query);
return $exporter->render($entries->getResults(), $query);
$oldMaxExecTime = \ini_get('max_execution_time');
ini_set('max_execution_time', $this->configuration->getExportTimeout());
$response = $exporter->render($entries->getResults(), $query);
ini_set('max_execution_time', $oldMaxExecTime);
return $response;
}
protected function multiUpdate(Request $request): Response

View File

@@ -396,6 +396,10 @@ final class Configuration implements ConfigurationInterface
->scalarPrototype()->end()
->defaultValue([])
->end()
->integerNode('timeout')
->defaultValue(60)
->end()
->end()
;

View File

@@ -49,7 +49,7 @@ class ExportTemplate
#[Assert\NotNull]
private array $columns = [];
/**
* @var array<string, mixed>
* @var array<string, int|string|null|bool>
*/
#[ORM\Column(name: 'options', type: Types::JSON, nullable: false)]
#[Assert\NotNull]
@@ -106,8 +106,22 @@ class ExportTemplate
$this->columns = $columns ?? [];
}
public function getOption(string $key, int|string|bool $default): int|string|bool
{
if (\array_key_exists($key, $this->options)) {
return $this->options[$key] ?? $default;
}
return $default;
}
public function setOption(string $key, int|string|null|bool $value): void
{
$this->options[$key] = $value;
}
/**
* @return array<string, mixed>
* @return array<string, int|string|null|bool>
*/
public function getOptions(): array
{
@@ -115,13 +129,30 @@ class ExportTemplate
}
/**
* @param array<string, mixed> $options
* @param array<string, int|string|null|bool> $options
*/
public function setOptions(?array $options): void
{
$this->options = $options ?? [];
}
/**
* Only used for CSV export
*/
public function setSeparator(string $separator): void
{
if (!\in_array($separator, [',', ';'], true)) {
throw new \InvalidArgumentException('Invalid separator, comma and semicolon are allowed.');
}
$this->setOption('separator', $separator);
}
public function getSeparator(): string
{
return (string) $this->getOption('separator', ',');
}
public function __toString(): string
{
return $this->title ?? 'New';

View File

@@ -914,6 +914,11 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
return $this;
}
/**
* Alias for setUserIdentifier()
*
* The visible username is setAlias())
*/
public function setUsername(string $username): void
{
$this->username = $username;

View File

@@ -13,7 +13,7 @@ use App\Entity\Invoice;
use Symfony\Contracts\EventDispatcher\Event;
/**
* This event can be used, to dynamically add meta fields to invoices
* This event can be used, to dynamically add meta-fields to invoices
*/
final class InvoiceMetaDefinitionEvent extends Event
{

View File

@@ -87,7 +87,12 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
$options = new Options();
$options->SHOULD_ADD_BOM = false;
$spreadsheet = new SpoutSpreadsheet(new Writer($options), $this->translator, $this->locale);
$opts = $this->spreadsheetRenderer->getTemplate()->getOptions();
if (\array_key_exists('separator', $opts) && $opts['separator'] === ';') {
$options->FIELD_DELIMITER = ';';
}
$spreadsheet = new SpoutSpreadsheet(new Writer($options), $this->translator, $this->locale ?? $this->spreadsheetRenderer->getTemplate()->getLocale());
$spreadsheet->open($filename);
$this->spreadsheetRenderer->registerFormatter('date', new DateStringFormatter());

View File

@@ -65,13 +65,14 @@ final class SpreadsheetRenderer
$this->template = $template;
}
private function getTemplate(): TemplateInterface
public function getTemplate(): TemplateInterface
{
if ($this->template === null) {
$template = new Template('default', 'default');
$template->setColumns($this->getDefaultColumns());
$template->setLocale('en');
return $template;
$this->template = $template;
}
return $this->template;
@@ -263,6 +264,8 @@ final class SpreadsheetRenderer
$columns[] = (new Column('alias', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
} elseif ($column === 'user.name') {
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getUserIdentifier())->withColumnWidth(ColumnWidth::MEDIUM);
} elseif ($column === 'user.email') {
$columns[] = (new Column('email', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getEmail())->withColumnWidth(ColumnWidth::MEDIUM);
} elseif ($column === 'user.account_number') {
$columns[] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber());
} elseif ($column === 'customer.name') {
@@ -336,6 +339,7 @@ final class SpreadsheetRenderer
'fixed_rate',
'user.alias',
'user.name',
'user.email',
'user.account_number',
'customer.name',
'project.name',

View File

@@ -81,7 +81,7 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
throw new \Exception('Could not open temporary file');
}
$spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator, $this->locale);
$spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator, $this->locale ?? $this->spreadsheetRenderer->getTemplate()->getLocale());
$spreadsheet->open($filename);
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);

View File

@@ -85,6 +85,7 @@ final class ServiceExport
$tpl = new Template((string) $template->getId(), $template->getTitle()); // @phpstan-ignore argument.type
$tpl->setColumns($template->getColumns());
$tpl->setLocale($template->getLanguage());
$tpl->setOptions($template->getOptions());
switch ($template->getRenderer()) {
case 'csv':

View File

@@ -14,6 +14,7 @@ use App\Form\Type\ExportColumnsType;
use App\Form\Type\ExportRendererType;
use App\Form\Type\LanguageType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -26,6 +27,7 @@ class ExportTemplateSpreadsheetForm extends AbstractType
$builder->add('renderer', ExportRendererType::class, ['label' => 'type']);
$builder->add('language', LanguageType::class, ['required' => false]);
$builder->add('columns', ExportColumnsType::class, ['required' => true]);
$builder->add('separator', ChoiceType::class, ['choices' => ['Comma (,)' => ',', 'Semicolon (;)' => ';'], 'required' => true]);
}
public function configureOptions(OptionsResolver $resolver): void

View File

@@ -61,7 +61,7 @@ final class QuickEntryTimesheetType extends AbstractType
}
if ($data instanceof Timesheet && !$this->security->isGranted('edit', $data)) {
$event->getForm()->remove('duration');
// do not call $event->getForm()->remove() this would change the field order
$event->getForm()->add('duration', DurationType::class, array_merge(['disabled' => true], $durationOptions));
$mainForm = $event->getForm()->getParent()?->getParent();
@@ -79,7 +79,7 @@ final class QuickEntryTimesheetType extends AbstractType
continue;
}
$type = \get_class($child->getConfig()->getType()->getInnerType());
$mainForm->remove($key);
// do not call $mainForm->remove() this would change the field order
$mainForm->add($key, $type, array_merge($child->getConfig()->getOptions(), ['disabled' => true]));
}
}