Release 2.7.0 (#4506)

* added api URL for simpler integration
* allow to request password change upon next login
* make ModifiedAt timesheet independent
* improved plugin api
* replace kernel calls with AutoconfigureTag and TaggedIterator attributes
* new translation keys (e.g. for days)
* support setting min and max date on date and daterange pickers
This commit is contained in:
Kevin Papst
2023-12-26 14:49:11 +01:00
committed by GitHub
parent f86eca05ae
commit ad645f5b58
72 changed files with 378 additions and 735 deletions

View File

@@ -66,7 +66,6 @@ final class StatusController extends BaseApiController
{
$plugins = [];
foreach ($pluginManager->getPlugins() as $plugin) {
$pluginManager->loadMetadata($plugin);
$plugins[] = new Plugin($plugin);
}

View File

@@ -16,6 +16,7 @@ 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\Console\Style\SymfonyStyle;
@@ -43,6 +44,7 @@ final class CreateUserCommand extends AbstractUserCommand
User::DEFAULT_ROLE
)
->addArgument('password', InputArgument::OPTIONAL, 'Password for the new user (requested if not provided)')
->addOption('request-password', null, InputOption::VALUE_NONE, 'The user needs to set a new password during next login')
;
}
@@ -69,6 +71,10 @@ final class CreateUserCommand extends AbstractUserCommand
$user->setEnabled(true);
$user->setRoles(explode(',', $role));
if ($input->getOption('request-password') === true) {
$user->setRequiresPasswordReset(true);
}
try {
$this->userService->saveNewUser($user);
$io->success(sprintf('Success! Created user: %s', $username));

View File

@@ -48,16 +48,16 @@ final class PluginCommand extends Command
$rows = [];
foreach ($plugins as $plugin) {
$this->plugins->loadMetadata($plugin);
$meta = $plugin->getMetadata();
$rows[] = [
$plugin->getId(),
$plugin->getName(),
$meta->getVersion(),
$meta->getKimaiVersion(),
$plugin->getPath(),
];
}
$io->table(['Name', 'Version', 'Requires', 'Directory'], $rows);
$io->table(['Id', 'Name', 'Version', 'Requires', 'Directory'], $rows);
return Command::SUCCESS;
}

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.6.0';
public const VERSION = '2.7.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 20600;
public const VERSION_ID = 20700;
/**
* The software name
*/

View File

@@ -28,7 +28,6 @@ final class PluginController extends AbstractController
$installed = [];
$plugins = $manager->getPlugins();
foreach ($plugins as $plugin) {
$manager->loadMetadata($plugin);
$installed[] = $plugin->getId();
}

View File

@@ -9,8 +9,10 @@
namespace App\DependencyInjection\Compiler;
use App\Export\ExportRepositoryInterface;
use App\Export\RendererInterface;
use App\Export\ServiceExport;
use App\Kernel;
use App\Export\TimesheetExportInterface;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
@@ -24,17 +26,17 @@ final class ExportServiceCompilerPass implements CompilerPassInterface
{
$definition = $container->findDefinition(ServiceExport::class);
$taggedRenderer = $container->findTaggedServiceIds(Kernel::TAG_EXPORT_RENDERER);
$taggedRenderer = $container->findTaggedServiceIds(RendererInterface::class);
foreach ($taggedRenderer as $id => $tags) {
$definition->addMethodCall('addRenderer', [new Reference($id)]);
}
$taggedExporter = $container->findTaggedServiceIds(Kernel::TAG_TIMESHEET_EXPORTER);
$taggedExporter = $container->findTaggedServiceIds(TimesheetExportInterface::class);
foreach ($taggedExporter as $id => $tags) {
$definition->addMethodCall('addTimesheetExporter', [new Reference($id)]);
}
$taggedRepository = $container->findTaggedServiceIds(Kernel::TAG_EXPORT_REPOSITORY);
$taggedRepository = $container->findTaggedServiceIds(ExportRepositoryInterface::class);
foreach ($taggedRepository as $id => $tags) {
$definition->addMethodCall('addExportRepository', [new Reference($id)]);
}

View File

@@ -9,8 +9,11 @@
namespace App\DependencyInjection\Compiler;
use App\Invoice\CalculatorInterface;
use App\Invoice\InvoiceItemRepositoryInterface;
use App\Invoice\NumberGeneratorInterface;
use App\Invoice\RendererInterface;
use App\Invoice\ServiceInvoice;
use App\Kernel;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
@@ -24,22 +27,22 @@ final class InvoiceServiceCompilerPass implements CompilerPassInterface
{
$definition = $container->findDefinition(ServiceInvoice::class);
$taggedRenderer = $container->findTaggedServiceIds(Kernel::TAG_INVOICE_RENDERER);
$taggedRenderer = $container->findTaggedServiceIds(RendererInterface::class);
foreach ($taggedRenderer as $id => $tags) {
$definition->addMethodCall('addRenderer', [new Reference($id)]);
}
$taggedGenerator = $container->findTaggedServiceIds(Kernel::TAG_INVOICE_NUMBER_GENERATOR);
$taggedGenerator = $container->findTaggedServiceIds(NumberGeneratorInterface::class);
foreach ($taggedGenerator as $id => $tags) {
$definition->addMethodCall('addNumberGenerator', [new Reference($id)]);
}
$taggedCalculator = $container->findTaggedServiceIds(Kernel::TAG_INVOICE_CALCULATOR);
$taggedCalculator = $container->findTaggedServiceIds(CalculatorInterface::class);
foreach ($taggedCalculator as $id => $tags) {
$definition->addMethodCall('addCalculator', [new Reference($id)]);
}
$taggedRepository = $container->findTaggedServiceIds(Kernel::TAG_INVOICE_REPOSITORY);
$taggedRepository = $container->findTaggedServiceIds(InvoiceItemRepositoryInterface::class);
foreach ($taggedRepository as $id => $tags) {
$definition->addMethodCall('addInvoiceItemRepository', [new Reference($id)]);
}

View File

@@ -9,7 +9,7 @@
namespace App\DependencyInjection\Compiler;
use App\Kernel;
use App\Widget\WidgetInterface;
use App\Widget\WidgetService;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -24,7 +24,7 @@ final class WidgetCompilerPass implements CompilerPassInterface
{
$definition = $container->findDefinition(WidgetService::class);
$taggedRenderer = $container->findTaggedServiceIds(Kernel::TAG_WIDGET);
$taggedRenderer = $container->findTaggedServiceIds(WidgetInterface::class);
foreach ($taggedRenderer as $id => $tags) {
$definition->addMethodCall('registerWidget', [new Reference($id)]);
}

View File

@@ -0,0 +1,15 @@
<?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\Doctrine;
interface ModifiedAt
{
public function setModifiedAt(\DateTimeImmutable $dateTime): void;
}

View File

@@ -9,14 +9,13 @@
namespace App\Doctrine;
use App\Entity\Timesheet;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
/**
* Automatically set the modifiedAt field for all Timesheet entries.
* Automatically set the modifiedAt field for all ModifiedAt instances.
*/
#[AsDoctrineListener(event: Events::onFlush, priority: 60)]
final class ModifiedSubscriber implements EventSubscriber, DataSubscriberInterface
@@ -34,17 +33,15 @@ final class ModifiedSubscriber implements EventSubscriber, DataSubscriberInterfa
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!($entity instanceof Timesheet)) {
continue;
if ($entity instanceof ModifiedAt) {
$entity->setModifiedAt($now);
}
$entity->setModifiedAt($now);
}
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!($entity instanceof Timesheet)) {
continue;
if ($entity instanceof ModifiedAt) {
$entity->setModifiedAt($now);
}
$entity->setModifiedAt($now);
}
}
}

View File

@@ -15,9 +15,12 @@ use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
/**
* A listener to make sure all Timesheet entries will be calculated properly (e.g. duration and rates).
* A listener to make sure all Timesheet entries will be calculated properly.
*
* E.g. updates timesheet records and applies configured rate & rounding rules.
*/
#[AsDoctrineListener(event: Events::onFlush, priority: 50)]
final class TimesheetSubscriber implements EventSubscriber, DataSubscriberInterface
@@ -30,7 +33,10 @@ final class TimesheetSubscriber implements EventSubscriber, DataSubscriberInterf
/**
* @param CalculatorInterface[] $calculators
*/
public function __construct(private iterable $calculators)
public function __construct(
#[TaggedIterator(CalculatorInterface::class)]
private readonly iterable $calculators
)
{
}

View File

@@ -9,6 +9,7 @@
namespace App\Entity;
use App\Doctrine\ModifiedAt;
use App\Validator\Constraints as Constraints;
use DateTime;
use DateTimeZone;
@@ -45,7 +46,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[Serializer\VirtualProperty('TagsAsArray', exp: 'object.getTagsAsArray()', options: [new Serializer\SerializedName('tags'), new Serializer\Type(name: 'array<string>'), new Serializer\Groups(['Default'])])]
#[Constraints\Timesheet]
#[Constraints\TimesheetDeactivated]
class Timesheet implements EntityWithMetaFields, ExportableItem
class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
{
/**
* Category: Normal work-time (default category)

View File

@@ -11,7 +11,9 @@ namespace App\Export;
use App\Entity\ExportableItem;
use App\Repository\Query\ExportQuery;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag]
interface ExportRepositoryInterface
{
/**

View File

@@ -9,6 +9,9 @@
namespace App\Export;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag]
interface RendererInterface extends ExportRendererInterface
{
}

View File

@@ -11,8 +11,10 @@ namespace App\Export;
use App\Entity\Timesheet;
use App\Repository\Query\TimesheetQuery;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\HttpFoundation\Response;
#[AutoconfigureTag]
interface TimesheetExportInterface
{
/**

View File

@@ -15,6 +15,8 @@ use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
@@ -50,6 +52,20 @@ class DatePickerType extends AbstractType
));
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
if ($options['min_day'] !== null) {
$view->vars['attr'] = array_merge($view->vars['attr'], [
'min' => $options['min_day'],
]);
}
if ($options['max_day'] !== null) {
$view->vars['attr'] = array_merge($view->vars['attr'], [
'max' => $options['max_day'],
]);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$format = $this->localeService->getDateFormat(\Locale::getDefault());
@@ -64,6 +80,8 @@ class DatePickerType extends AbstractType
'model_timezone' => date_default_timezone_get(),
'view_timezone' => date_default_timezone_get(),
'force_time' => null,
'min_day' => null,
'max_day' => null,
]);
}

View File

@@ -50,6 +50,8 @@ final class DateRangeType extends AbstractType
'separator' => self::DATE_SPACER,
'allow_empty' => true,
'with_presets' => true,
'min_day' => null,
'max_day' => null,
'attr' => [
'pattern' => $pattern . self::DATE_SPACER . $pattern
],
@@ -87,6 +89,18 @@ final class DateRangeType extends AbstractType
$view->vars['attr'] = array_merge($view->vars['attr'], [
'data-separator' => $options['separator'],
]);
if ($options['min_day'] !== null) {
$view->vars['attr'] = array_merge($view->vars['attr'], [
'min' => $options['min_day'],
]);
}
if ($options['max_day'] !== null) {
$view->vars['attr'] = array_merge($view->vars['attr'], [
'max' => $options['max_day'],
]);
}
}
public function buildForm(FormBuilderInterface $builder, array $options): void

View File

@@ -9,9 +9,12 @@
namespace App\Invoice;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
/**
* CalculatorInterface defines all methods for any invoice price calculator.
*/
#[AutoconfigureTag]
interface CalculatorInterface
{
/**

View File

@@ -11,7 +11,9 @@ namespace App\Invoice;
use App\Entity\ExportableItem;
use App\Repository\Query\InvoiceQuery;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag]
interface InvoiceItemRepositoryInterface
{
/**

View File

@@ -9,9 +9,12 @@
namespace App\Invoice;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
/**
* Class NumberGeneratorInterface defines all methods that invoice number generator have to implement.
*/
#[AutoconfigureTag]
interface NumberGeneratorInterface
{
public function setModel(InvoiceModel $model): void;

View File

@@ -10,8 +10,10 @@
namespace App\Invoice;
use App\Model\InvoiceDocument;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\HttpFoundation\Response;
#[AutoconfigureTag]
interface RendererInterface
{
/**

View File

@@ -14,22 +14,9 @@ use App\DependencyInjection\Compiler\ExportServiceCompilerPass;
use App\DependencyInjection\Compiler\InvoiceServiceCompilerPass;
use App\DependencyInjection\Compiler\TwigContextCompilerPass;
use App\DependencyInjection\Compiler\WidgetCompilerPass;
use App\Export\ExportRepositoryInterface;
use App\Export\RendererInterface as ExportRendererInterface;
use App\Export\TimesheetExportInterface;
use App\Invoice\CalculatorInterface as InvoiceCalculator;
use App\Invoice\InvoiceItemRepositoryInterface;
use App\Invoice\NumberGeneratorInterface;
use App\Invoice\RendererInterface as InvoiceRendererInterface;
use App\Ldap\FormLoginLdapFactory;
use App\Plugin\PluginInterface;
use App\Plugin\PluginMetadata;
use App\Timesheet\CalculatorInterface as TimesheetCalculator;
use App\Timesheet\Rounding\RoundingInterface;
use App\Timesheet\TrackingMode\TrackingModeInterface;
use App\Validator\Constraints\ProjectConstraint;
use App\Validator\Constraints\TimesheetConstraint;
use App\Widget\WidgetInterface;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Bundle\SecurityBundle\DependencyInjection\SecurityExtension;
use Symfony\Component\Config\Loader\LoaderInterface;
@@ -47,21 +34,6 @@ class Kernel extends BaseKernel
public const PLUGIN_DIRECTORY = '/var/plugins';
public const CONFIG_EXTS = '.{php,yaml}';
public const TAG_PLUGIN = 'kimai.plugin';
public const TAG_WIDGET = 'widget';
public const TAG_EXPORT_RENDERER = 'export.renderer';
public const TAG_EXPORT_REPOSITORY = 'export.repository';
public const TAG_INVOICE_RENDERER = 'invoice.renderer';
public const TAG_INVOICE_NUMBER_GENERATOR = 'invoice.number_generator';
public const TAG_INVOICE_CALCULATOR = 'invoice.calculator';
public const TAG_INVOICE_REPOSITORY = 'invoice.repository';
public const TAG_TIMESHEET_CALCULATOR = 'timesheet.calculator';
public const TAG_TIMESHEET_VALIDATOR = 'timesheet.validator';
public const TAG_TIMESHEET_EXPORTER = 'timesheet.exporter';
public const TAG_TIMESHEET_TRACKING_MODE = 'timesheet.tracking_mode';
public const TAG_TIMESHEET_ROUNDING_MODE = 'timesheet.rounding_mode';
public const TAG_PROJECT_VALIDATOR = 'project.validator';
public function getCacheDir(): string
{
return $this->getProjectDir() . '/var/cache/' . $this->environment;
@@ -74,21 +46,6 @@ class Kernel extends BaseKernel
protected function build(ContainerBuilder $container): void
{
$container->registerForAutoconfiguration(TimesheetCalculator::class)->addTag(self::TAG_TIMESHEET_CALCULATOR);
$container->registerForAutoconfiguration(ExportRendererInterface::class)->addTag(self::TAG_EXPORT_RENDERER);
$container->registerForAutoconfiguration(ExportRepositoryInterface::class)->addTag(self::TAG_EXPORT_REPOSITORY);
$container->registerForAutoconfiguration(InvoiceRendererInterface::class)->addTag(self::TAG_INVOICE_RENDERER);
$container->registerForAutoconfiguration(NumberGeneratorInterface::class)->addTag(self::TAG_INVOICE_NUMBER_GENERATOR);
$container->registerForAutoconfiguration(InvoiceCalculator::class)->addTag(self::TAG_INVOICE_CALCULATOR);
$container->registerForAutoconfiguration(InvoiceItemRepositoryInterface::class)->addTag(self::TAG_INVOICE_REPOSITORY);
$container->registerForAutoconfiguration(PluginInterface::class)->addTag(self::TAG_PLUGIN);
$container->registerForAutoconfiguration(WidgetInterface::class)->addTag(self::TAG_WIDGET);
$container->registerForAutoconfiguration(TimesheetExportInterface::class)->addTag(self::TAG_TIMESHEET_EXPORTER);
$container->registerForAutoconfiguration(TrackingModeInterface::class)->addTag(self::TAG_TIMESHEET_TRACKING_MODE);
$container->registerForAutoconfiguration(RoundingInterface::class)->addTag(self::TAG_TIMESHEET_ROUNDING_MODE);
$container->registerForAutoconfiguration(TimesheetConstraint::class)->addTag(self::TAG_TIMESHEET_VALIDATOR);
$container->registerForAutoconfiguration(ProjectConstraint::class)->addTag(self::TAG_PROJECT_VALIDATOR);
/** @var SecurityExtension $extension */
$extension = $container->getExtension('security');
$extension->addAuthenticatorFactory(new FormLoginLdapFactory());
@@ -152,7 +109,7 @@ class Kernel extends BaseKernel
throw new \Exception(sprintf('Bundle "%s" does not implement %s, which is not supported since 2.0.', $bundleName, PluginInterface::class));
}
$meta = PluginMetadata::loadFromComposer($fullPath);
$meta = new PluginMetadata($fullPath);
if ($meta->getKimaiVersion() > Constants::VERSION_ID) {
throw new \Exception(sprintf('Bundle "%s" requires minimum Kimai version %s, but yours is lower: %s (%s). Please update Kimai or use a lower Plugin version.', $bundleName, $meta->getKimaiVersion(), Constants::VERSION, Constants::VERSION_ID));
@@ -173,7 +130,7 @@ class Kernel extends BaseKernel
$confDir = $this->getProjectDir() . '/config';
// using this one instead of $loader->load($confDir . '/packages/*' . self::CONFIG_EXTS, 'glob');
// to get rid of the local.yaml from the list, we load it afterwards explicit
// to get rid of the local.yaml from the list, we load it afterward explicit
$finder = (new Finder())
->files()
->in([$confDir . '/packages/'])
@@ -199,8 +156,7 @@ class Kernel extends BaseKernel
$container->addCompilerPass(new WidgetCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
}
/** @phpstan-ignore-next-line */
private function configureRoutes(RoutingConfigurator $routes): void
private function configureRoutes(RoutingConfigurator $routes): void // @phpstan-ignore-line
{
$configDir = $this->getConfigDir();

View File

@@ -11,42 +11,38 @@ namespace App\Plugin;
final class Plugin
{
private string $id;
private string $path;
private ?PluginMetadata $metadata = null;
public function __construct(PluginInterface $bundle)
public function __construct(private readonly PluginInterface $bundle)
{
$this->id = $bundle->getName();
$this->path = $bundle->getPath();
}
public function getMetadata(): ?PluginMetadata
public function getMetadata(): PluginMetadata
{
if ($this->metadata === null) {
$this->metadata = new PluginMetadata($this->getPath());
}
return $this->metadata;
}
public function setMetadata(PluginMetadata $metadata): void
{
$this->metadata = $metadata;
}
public function getPath(): string
{
return $this->path;
return $this->bundle->getPath();
}
public function getName(): string
{
if ($this->metadata !== null && $this->metadata->getName() !== null) {
return $this->metadata->getName();
$meta = $this->getMetadata();
if ($meta->getName() !== null) {
return $meta->getName();
}
return $this->id;
return $this->getId();
}
public function getId(): string
{
return $this->id;
return $this->bundle->getName();
}
}

View File

@@ -9,6 +9,9 @@
namespace App\Plugin;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag]
interface PluginInterface
{
public function getName(): string;

View File

@@ -9,23 +9,23 @@
namespace App\Plugin;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
final class PluginManager
{
/**
* @var array<Plugin>|null
*/
private ?array $plugins = null;
/**
* @var iterable<PluginInterface>
*/
private iterable $bundles;
/**
* @param iterable<PluginInterface> $plugins
* @param iterable<PluginInterface> $bundles
*/
public function __construct(iterable $plugins)
public function __construct(
#[TaggedIterator(PluginInterface::class)]
private readonly iterable $bundles
)
{
$this->bundles = $plugins;
}
/**
@@ -46,29 +46,27 @@ final class PluginManager
return $this->plugins;
}
public function hasPlugin(string $name): bool
{
foreach ($this->bundles as $plugin) {
if ($plugin->getName() === $name) {
return true;
}
}
return false;
}
public function getPlugin(string $name): ?Plugin
{
$plugins = $this->getPlugins();
foreach ($plugins as $plugin) {
if ($plugin->getName() === $name) {
if ($plugin->getId() === $name) {
return $plugin;
}
}
return null;
}
/**
* Call this method and pass a plugin, to set its metadata.
* This is not pre-filled by default, as it would mean to parse several composer.json on each request.
*
* @param Plugin $plugin
* @throws \Exception
*/
public function loadMetadata(Plugin $plugin): void
{
$meta = PluginMetadata::loadFromComposer($plugin->getPath());
$plugin->setMetadata($meta);
}
}

View File

@@ -20,11 +20,9 @@ class PluginMetadata
private ?string $name = null;
/**
* @param string $path
* @return PluginMetadata
* @throws \Exception
*/
public static function loadFromComposer(string $path): PluginMetadata
public function __construct(string $path)
{
if (!is_dir($path) || !is_readable($path)) {
throw new \Exception(sprintf('Bundle directory "%s" cannot be accessed.', $path));
@@ -59,16 +57,13 @@ class PluginMetadata
throw new \Exception(sprintf('Bundle "%s" defines an invalid Kimai minimum version in extra.kimai.require. Please provide an integer as in Constants::VERSION_ID.', $pluginName));
}
$meta = new self();
$meta->description = $json['description'] ?? '';
$meta->homepage = $json['homepage'] ?? Constants::HOMEPAGE . '/store/';
$meta->name = $json['extra']['kimai']['name'];
$meta->kimaiVersion = $json['extra']['kimai']['require'];
$this->description = $json['description'] ?? '';
$this->homepage = $json['homepage'] ?? Constants::HOMEPAGE . '/store/';
$this->name = $json['extra']['kimai']['name'];
$this->kimaiVersion = $json['extra']['kimai']['require'];
// the version field is required if we use composer to install a plugin via var/packages/
$meta->version = $json['extra']['kimai']['version'] ?? ($json['version'] ?? 'unknown');
return $meta;
$this->version = $json['extra']['kimai']['version'] ?? ($json['version'] ?? 'unknown');
}
public function getDescription(): ?string

View File

@@ -10,11 +10,13 @@
namespace App\Timesheet;
use App\Entity\Timesheet;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
/**
* A calculator is called before a Timesheet entity will be updated.
* These classes will normally be used when calculating duration or rates.
*/
#[AutoconfigureTag]
interface CalculatorInterface
{
/**

View File

@@ -10,10 +10,12 @@
namespace App\Timesheet\Rounding;
use App\Entity\Timesheet;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
/**
* Apply rounding rules to the given timesheet.
*/
#[AutoconfigureTag]
interface RoundingInterface
{
public function roundBegin(Timesheet $record, int $minutes): void;

View File

@@ -12,45 +12,59 @@ namespace App\Timesheet;
use App\Configuration\SystemConfiguration;
use App\Entity\Timesheet;
use App\Timesheet\Rounding\RoundingInterface;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
final class RoundingService
{
/**
* @var array
* @var array<string, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}>
*/
private $rulesCache;
private ?array $rulesCache = null;
/**
* @param SystemConfiguration $configuration
* @param RoundingInterface[] $roundingModes
* @param array $rules
* @param array<string, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}> $rules
*/
public function __construct(private SystemConfiguration $configuration, private iterable $roundingModes, private array $rules)
public function __construct(
private readonly SystemConfiguration $configuration,
#[TaggedIterator(RoundingInterface::class)]
private readonly iterable $roundingModes,
private readonly array $rules
)
{
}
/**
* @return array<string, array{'days': array<string>, 'begin': int, 'end': int, 'duration': int, 'mode': string}>
*/
private function getRoundingRules(): array
{
if (empty($this->rulesCache)) {
$this->rulesCache = $this->rules;
if (empty($this->rulesCache) || \array_key_exists('default', $this->rulesCache)) {
$this->rulesCache['default']['days'] = $this->configuration->getTimesheetDefaultRoundingDays();
$this->rulesCache['default']['begin'] = $this->configuration->getTimesheetDefaultRoundingBegin();
$this->rulesCache['default']['end'] = $this->configuration->getTimesheetDefaultRoundingEnd();
$this->rulesCache['default']['duration'] = $this->configuration->getTimesheetDefaultRoundingDuration();
$this->rulesCache['default']['mode'] = $this->configuration->getTimesheetDefaultRoundingMode();
}
if ($this->rulesCache === null) {
$rules = $this->rules;
$rules['default']['days'] = $this->configuration->getTimesheetDefaultRoundingDays();
$rules['default']['begin'] = $this->configuration->getTimesheetDefaultRoundingBegin();
$rules['default']['end'] = $this->configuration->getTimesheetDefaultRoundingEnd();
$rules['default']['duration'] = $this->configuration->getTimesheetDefaultRoundingDuration();
$rules['default']['mode'] = $this->configuration->getTimesheetDefaultRoundingMode();
// see AppExtension, conversion from string to array due to system configuration ont allowing to store arrays
foreach ($this->rulesCache as $key => $settings) {
// see AppExtension, conversion from string to array due to system configuration not allowing to store arrays
foreach ($rules as $key => $settings) {
if (\is_array($settings['days'])) {
continue;
}
if ($settings['days'] === '') {
$rules[$key]['days'] = [];
continue;
}
$days = explode(',', $settings['days']);
$days = array_map('trim', $days);
$days = array_map('strtolower', $days);
$this->rulesCache[$key]['days'] = $days;
$rules[$key]['days'] = $days;
}
$this->rulesCache = $rules; // @phpstan-ignore-line
}
return $this->rulesCache;
return $this->rulesCache; // @phpstan-ignore-line
}
public function roundBegin(Timesheet $record): void
@@ -61,7 +75,7 @@ final class RoundingService
}
$weekday = $record->getBegin()->format('l');
if (\in_array(strtolower($weekday), $rounding['days'])) {
if (\in_array(strtolower($weekday), $rounding['days'], true)) {
$rounder = $this->getRoundingMode($rounding['mode']);
$rounder->roundBegin($record, $rounding['begin']);
}
@@ -76,7 +90,7 @@ final class RoundingService
}
$weekday = $record->getEnd()->format('l');
if (\in_array(strtolower($weekday), $rounding['days'])) {
if (\in_array(strtolower($weekday), $rounding['days'], true)) {
$rounder = $this->getRoundingMode($rounding['mode']);
$rounder->roundEnd($record, $rounding['end']);
}
@@ -91,7 +105,7 @@ final class RoundingService
}
$weekday = $record->getEnd()->format('l');
if (\in_array(strtolower($weekday), $rounding['days'])) {
if (\in_array(strtolower($weekday), $rounding['days'], true)) {
$rounder = $this->getRoundingMode($rounding['mode']);
$rounder->roundDuration($record, $rounding['duration']);
}
@@ -110,7 +124,7 @@ final class RoundingService
}
$weekday = $record->getEnd()->format('l');
if (\in_array(strtolower($weekday), $rounding['days'])) {
if (\in_array(strtolower($weekday), $rounding['days'], true)) {
$rounder = $this->getRoundingMode($rounding['mode']);
$rounder->roundBegin($record, $rounding['begin']);
$rounder->roundEnd($record, $rounding['end']);

View File

@@ -10,6 +10,7 @@
namespace App\Timesheet\TrackingMode;
use App\Entity\Timesheet;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\HttpFoundation\Request;
/**
@@ -18,6 +19,7 @@ use Symfony\Component\HttpFoundation\Request;
*
* @internal do not implement this interface in your bundle, but rather drop a PR to add it to Kimai core
*/
#[AutoconfigureTag]
interface TrackingModeInterface
{
/**

View File

@@ -11,6 +11,7 @@ namespace App\Timesheet;
use App\Configuration\SystemConfiguration;
use App\Timesheet\TrackingMode\TrackingModeInterface;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
final class TrackingModeService
@@ -19,7 +20,11 @@ final class TrackingModeService
* @param SystemConfiguration $configuration
* @param TrackingModeInterface[] $modes
*/
public function __construct(private SystemConfiguration $configuration, private iterable $modes)
public function __construct(
private readonly SystemConfiguration $configuration,
#[TaggedIterator(TrackingModeInterface::class)]
private readonly iterable $modes
)
{
}

View File

@@ -9,11 +9,13 @@
namespace App\Validator\Constraints;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\Validator\Constraint;
/**
* Extend this class if you want to add dynamic project validation (eg. via a bundle).
*/
#[AutoconfigureTag]
abstract class ProjectConstraint extends Constraint
{
}

View File

@@ -10,7 +10,8 @@
namespace App\Validator\Constraints;
use App\Entity\Project;
use App\Validator\Constraints\Project as ProjectConstraint;
use App\Validator\Constraints\Project as ProjectEntityConstraint;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -19,9 +20,12 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class ProjectValidator extends ConstraintValidator
{
/**
* @param Constraint[] $constraints
* @param ProjectConstraint[] $constraints
*/
public function __construct(private iterable $constraints = [])
public function __construct(
#[TaggedIterator(ProjectConstraint::class)]
private iterable $constraints = []
)
{
}
@@ -31,8 +35,8 @@ final class ProjectValidator extends ConstraintValidator
*/
public function validate(mixed $value, Constraint $constraint): void
{
if (!($constraint instanceof ProjectConstraint)) {
throw new UnexpectedTypeException($constraint, ProjectConstraint::class);
if (!($constraint instanceof ProjectEntityConstraint)) {
throw new UnexpectedTypeException($constraint, ProjectEntityConstraint::class);
}
if (!\is_object($value) || !($value instanceof Project)) {
@@ -52,10 +56,10 @@ final class ProjectValidator extends ConstraintValidator
protected function validateProject(Project $project, ExecutionContextInterface $context): void
{
if (null !== $project->getStart() && null !== $project->getEnd() && $project->getStart()->getTimestamp() > $project->getEnd()->getTimestamp()) {
$context->buildViolation(ProjectConstraint::getErrorName(ProjectConstraint::END_BEFORE_BEGIN_ERROR))
$context->buildViolation(ProjectEntityConstraint::getErrorName(ProjectEntityConstraint::END_BEFORE_BEGIN_ERROR))
->atPath('end')
->setTranslationDomain('validators')
->setCode(ProjectConstraint::END_BEFORE_BEGIN_ERROR)
->setCode(ProjectEntityConstraint::END_BEFORE_BEGIN_ERROR)
->addViolation();
}
}

View File

@@ -11,6 +11,7 @@ namespace App\Validator\Constraints;
use App\Entity\Timesheet as TimesheetEntity;
use App\Validator\Constraints\QuickEntryTimesheet as QuickEntryTimesheetConstraint;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
@@ -18,9 +19,12 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class QuickEntryTimesheetValidator extends ConstraintValidator
{
/**
* @param Constraint[] $constraints
* @param TimesheetConstraint[] $constraints
*/
public function __construct(private iterable $constraints)
public function __construct(
#[TaggedIterator(TimesheetConstraint::class)]
private iterable $constraints
)
{
}

View File

@@ -9,11 +9,13 @@
namespace App\Validator\Constraints;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\Validator\Constraint;
/**
* Extend this class if you want to add dynamic timesheet validation (eg. via a bundle).
*/
#[AutoconfigureTag]
abstract class TimesheetConstraint extends Constraint
{
}

View File

@@ -10,7 +10,8 @@
namespace App\Validator\Constraints;
use App\Entity\Timesheet as TimesheetEntity;
use App\Validator\Constraints\Timesheet as TimesheetConstraint;
use App\Validator\Constraints\Timesheet as TimesheetEntityConstraint;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
@@ -18,9 +19,12 @@ use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class TimesheetValidator extends ConstraintValidator
{
/**
* @param Constraint[] $constraints
* @param TimesheetConstraint[] $constraints
*/
public function __construct(private iterable $constraints)
public function __construct(
#[TaggedIterator(TimesheetConstraint::class)]
private iterable $constraints
)
{
}
@@ -30,8 +34,8 @@ final class TimesheetValidator extends ConstraintValidator
*/
public function validate(mixed $timesheet, Constraint $constraint): void
{
if (!($constraint instanceof TimesheetConstraint)) {
throw new UnexpectedTypeException($constraint, Timesheet::class);
if (!($constraint instanceof TimesheetEntityConstraint)) {
throw new UnexpectedTypeException($constraint, TimesheetEntityConstraint::class);
}
if (!\is_object($timesheet) || !($timesheet instanceof TimesheetEntity)) {

View File

@@ -10,12 +10,14 @@
namespace App\Widget;
use App\Entity\User;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\Form\Form;
/**
* No BC promise!
* Use AbstractWidget to get a BC safe base class.
*/
#[AutoconfigureTag]
interface WidgetInterface
{
public const COLOR_TODAY = 'green';