added database driven system configurations with admin screen (#647)

This commit is contained in:
Kevin Papst
2019-03-22 20:58:38 +01:00
committed by GitHub
parent e990ae4800
commit 13d9f8f3f7
142 changed files with 2262 additions and 64727 deletions

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace App\API;
use App\Configuration\TimesheetConfiguration;
use App\Entity\Timesheet;
use App\Form\TimesheetEditForm;
use App\Repository\Query\TimesheetQuery;
@@ -37,16 +38,14 @@ class TimesheetController extends BaseApiController
* @var TimesheetRepository
*/
protected $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
/**
* @var int
* @var TimesheetConfiguration
*/
protected $hardLimit;
protected $configuration;
/**
* @var UserDateTimeFactory
@@ -57,13 +56,13 @@ class TimesheetController extends BaseApiController
* @param ViewHandlerInterface $viewHandler
* @param TimesheetRepository $repository
* @param UserDateTimeFactory $dateTime
* @param int $hardLimit
* @param TimesheetConfiguration $configuration
*/
public function __construct(ViewHandlerInterface $viewHandler, TimesheetRepository $repository, UserDateTimeFactory $dateTime, int $hardLimit)
public function __construct(ViewHandlerInterface $viewHandler, TimesheetRepository $repository, UserDateTimeFactory $dateTime, TimesheetConfiguration $configuration)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
$this->hardLimit = $hardLimit;
$this->configuration = $configuration;
$this->dateTime = $dateTime;
}
@@ -235,7 +234,7 @@ class TimesheetController extends BaseApiController
if (null === $timesheet->getEnd()) {
$this->repository->stopActiveEntries(
$timesheet->getUser(),
$this->hardLimit
$this->configuration->getActiveEntriesHardLimit()
);
}

View File

@@ -21,7 +21,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
*/
class CreateReleaseCommand extends Command
{
const CLONE_CMD = 'git clone -b %s --depth 1 https://github.com/kevinpapst/kimai2.git';
public const CLONE_CMD = 'git clone -b %s --depth 1 https://github.com/kevinpapst/kimai2.git';
/**
* @var string
@@ -62,6 +62,7 @@ class CreateReleaseCommand extends Command
if (getenv('APP_ENV') === 'prod') {
$io->error('kimai:create-release is not allowed in production');
return -2;
}
@@ -77,11 +78,13 @@ class CreateReleaseCommand extends Command
if (!is_dir($directory)) {
$io->error('Given directory is not existing: ' . $directory);
return 1;
}
if (is_dir($directory) && !is_writable($directory)) {
$io->error('Cannot write in directory: ' . $directory);
return 1;
}
@@ -123,29 +126,30 @@ class CreateReleaseCommand extends Command
'var/sessions/*',
];
foreach($filesToDelete as $deleteMe) {
foreach ($filesToDelete as $deleteMe) {
$commands['Delete ' . $deleteMe] = 'cd ' . $tmpDir . ' && rm -rf ' . $deleteMe;
}
$commands = array_merge($commands, [
'Create tar' => 'cd ' . $tmpDir . ' && tar -czf ' . $directory. '/' . $tar . ' .',
'Create zip' => 'cd ' . $tmpDir . ' && zip -r ' . $directory. '/' . $zip . ' .',
'Create tar' => 'cd ' . $tmpDir . ' && tar -czf ' . $directory . '/' . $tar . ' .',
'Create zip' => 'cd ' . $tmpDir . ' && zip -r ' . $directory . '/' . $zip . ' .',
'Remove tmp directory' => 'rm -rf ' . $tmpDir,
]);
$exitCode = 0;
foreach($commands as $title => $command) {
foreach ($commands as $title => $command) {
$io->success($title);
passthru($command, $exitCode);
if ($exitCode !== 0) {
$io->error('Failed with command: ' . $command);
return -1;
}
}
$io->success(
'New release packages available at: ' . PHP_EOL .
$directory . '/' . $tar. PHP_EOL .
$directory . '/' . $tar . PHP_EOL .
$directory . '/' . $zip
);

View File

@@ -51,6 +51,7 @@ EOT
if (getenv('APP_ENV') === 'prod') {
$io->error('kimai:reset-dev is not allowed in production');
return -1;
}

View File

@@ -0,0 +1,21 @@
<?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\Configuration;
use App\Entity\Configuration;
interface ConfigLoaderInterface
{
/**
* @param null|string $prefix
* @return Configuration[]
*/
public function getConfiguration(?string $prefix = null): array;
}

View File

@@ -0,0 +1,35 @@
<?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\Configuration;
class FormConfiguration implements SystemBundleConfiguration
{
use StringAccessibleConfigTrait;
public function getPrefix(): string
{
return 'defaults';
}
public function getCustomerDefaultTimezone(): string
{
return $this->find('customer.timezone');
}
public function getCustomerDefaultCurrency(): string
{
return $this->find('customer.currency');
}
public function getCustomerDefaultCountry(): string
{
return $this->find('customer.country');
}
}

View File

@@ -0,0 +1,123 @@
<?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\Configuration;
use App\Entity\Configuration;
trait StringAccessibleConfigTrait
{
/**
* @var array
*/
protected $settings;
/**
* @var ConfigLoaderInterface
*/
protected $repository;
/**
* @var bool
*/
protected $initialized = false;
/**
* @param array $settings
*/
public function __construct(ConfigLoaderInterface $repository, array $settings)
{
$this->repository = $repository;
$this->settings = $settings;
}
/**
* @param ConfigLoaderInterface $repository
* @return Configuration[]
*/
protected function getConfigurations(ConfigLoaderInterface $repository): array
{
return $repository->getConfiguration($this->getPrefix() . '.');
}
protected function prepare()
{
if ($this->initialized) {
return;
}
// this foreach should be replaced by a better piece of code,
// especially the pointers could be a problem in the future
foreach ($this->getConfigurations($this->repository) as $configuration) {
$temp = explode('.', $configuration->getName());
$array = &$this->settings;
if ($temp[0] === $this->getPrefix()) {
$temp = array_slice($temp, 1);
}
foreach ($temp as $key2) {
if (!isset($array[$key2])) {
// unknown values will silently be skipped
continue 2;
}
if (is_array($array[$key2])) {
$array = &$array[$key2];
} elseif (is_bool($array[$key2])) {
$array[$key2] = (bool) $configuration->getValue();
} elseif (is_int($array[$key2])) {
$array[$key2] = (int) $configuration->getValue();
} else {
$array[$key2] = $configuration->getValue();
}
}
}
$this->initialized = true;
}
/**
* @return string
*/
abstract protected function getPrefix(): string;
/**
* @param string $key
* @return mixed
*/
public function find(string $key)
{
$this->prepare();
$prefix = $this->getPrefix() . '.';
$length = strlen($prefix);
if (substr($key, 0, $length) === $prefix) {
$key = substr($key, $length);
}
return $this->get($key, $this->settings);
}
/**
* @param string $key
* @param array $config
* @return mixed
*/
private function get(string $key, array $config)
{
$keys = explode('.', $key);
$search = array_shift($keys);
if (!isset($config[$search])) {
throw new \InvalidArgumentException('Unknown config: ' . $key);
}
if (is_array($config[$search]) && !empty($keys)) {
return $this->get(implode('.', $keys), $config[$search]);
}
return $config[$search];
}
}

View File

@@ -0,0 +1,24 @@
<?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\Configuration;
interface SystemBundleConfiguration
{
/**
* @return string
*/
public function getPrefix(): string;
/**
* @param string $key
* @return mixed
*/
public function find(string $key);
}

View File

@@ -0,0 +1,25 @@
<?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\Configuration;
class SystemConfiguration implements SystemBundleConfiguration
{
use StringAccessibleConfigTrait;
public function getPrefix(): string
{
return 'kimai';
}
protected function getConfigurations(ConfigLoaderInterface $repository): array
{
return $repository->getConfiguration();
}
}

View File

@@ -0,0 +1,45 @@
<?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\Configuration;
class TimesheetConfiguration implements SystemBundleConfiguration
{
use StringAccessibleConfigTrait;
public function getPrefix(): string
{
return 'timesheet';
}
public function isAllowFutureTimes(): bool
{
return (bool) $this->find('rules.allow_future_times');
}
public function isDurationOnly(): bool
{
return (bool) $this->find('duration_only');
}
public function isMarkdownEnabled(): bool
{
return (bool) $this->find('markdown_content');
}
public function getActiveEntriesHardLimit(): int
{
return (int) $this->find('active_entries.hard_limit');
}
public function getActiveEntriesSoftLimit(): int
{
return (int) $this->find('active_entries.soft_limit');
}
}

View File

@@ -9,6 +9,7 @@
namespace App\Controller;
use App\Configuration\FormConfiguration;
use App\Entity\Customer;
use App\Form\CustomerEditForm;
use App\Form\Toolbar\CustomerToolbarForm;
@@ -30,16 +31,22 @@ use Symfony\Component\Routing\Annotation\Route;
class CustomerController extends AbstractController
{
/**
* @var array
* @var CustomerRepository
*/
private $defaults;
private $repository;
/**
* @var FormConfiguration
*/
private $configuration;
/**
* @param array $defaults
* @param CustomerRepository $repository
* @param FormConfiguration $configuration
*/
public function __construct(array $defaults)
public function __construct(CustomerRepository $repository, FormConfiguration $configuration)
{
$this->defaults = $defaults;
$this->repository = $repository;
$this->configuration = $configuration;
}
/**
@@ -47,7 +54,7 @@ class CustomerController extends AbstractController
*/
protected function getRepository()
{
return $this->getDoctrine()->getRepository(Customer::class);
return $this->repository;
}
/**
@@ -95,9 +102,9 @@ class CustomerController extends AbstractController
public function createAction(Request $request)
{
$customer = new Customer();
$customer->setCountry($this->defaults['customer']['country']);
$customer->setCurrency($this->defaults['customer']['currency']);
$customer->setTimezone($this->defaults['customer']['timezone']);
$customer->setCountry($this->configuration->getCustomerDefaultCountry());
$customer->setCurrency($this->configuration->getCustomerDefaultCurrency());
$customer->setTimezone($this->configuration->getCustomerDefaultTimezone());
return $this->renderCustomerForm($customer, $request);
}

View File

@@ -0,0 +1,246 @@
<?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\Controller;
use App\Configuration\SystemConfiguration;
use App\Event\SystemConfigurationEvent;
use App\Form\Model\Configuration;
use App\Form\Model\SystemConfiguration as SystemConfigurationModel;
use App\Form\SystemConfigurationForm;
use App\Repository\ConfigurationRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\CurrencyType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TimezoneType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Constraints\GreaterThanOrEqual;
/**
* Controller used for executing system relevant tasks.
*
* @Route(path="/admin/system-config")
* @Security("is_granted('system_configuration')")
*/
class SystemConfigurationController extends AbstractController
{
/**
* @var EventDispatcherInterface
*/
protected $eventDispatcher;
/**
* @var SystemConfiguration
*/
protected $configurations;
/**
* @var ConfigurationRepository
*/
protected $repository;
/**
* @param EventDispatcherInterface $dispatcher
* @param ConfigurationRepository $repository
* @param SystemConfiguration $config
*/
public function __construct(EventDispatcherInterface $dispatcher, ConfigurationRepository $repository, SystemConfiguration $config)
{
$this->eventDispatcher = $dispatcher;
$this->repository = $repository;
$this->configurations = $config;
}
/**
* @Route(path="/", name="system_configuration", methods={"GET"})
*
* @return \Symfony\Component\HttpFoundation\Response
*/
public function indexAction()
{
$configSettings = $this->getInitializedConfigurations();
$configurations = [];
foreach ($configSettings as $configModel) {
$configurations[] = [
'model' => $configModel,
'form' => $this->createConfigurationsForm($configModel)->createView(),
];
}
return $this->render('system-configuration/index.html.twig', [
'sections' => $configurations,
]);
}
/**
* @Route(path="/timesheet", name="system_configuration_timesheet", methods={"POST"})
*
* @return \Symfony\Component\HttpFoundation\Response
*/
public function timesheet(Request $request)
{
return $this->handleConfigUpdate($request, SystemConfigurationModel::SECTION_TIMESHEET);
}
/**
* @Route(path="/customer", name="system_configuration_form_customer", methods={"POST"})
*
* @return \Symfony\Component\HttpFoundation\Response
*/
public function formDefaults(Request $request)
{
return $this->handleConfigUpdate($request, SystemConfigurationModel::SECTION_FORM_CUSTOMER);
}
/**
* @param Request $request
* @param string $section
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
protected function handleConfigUpdate(Request $request, string $section)
{
$configSettings = $this->getInitializedConfigurations();
foreach ($configSettings as $configModel) {
if ($configModel->getSection() === $section) {
break;
}
}
if (null === $configModel) {
throw $this->createNotFoundException('Could not find config model: ' . $section);
}
$form = $this->createConfigurationsForm($configModel);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->repository->saveSystemConfiguration($form->getData());
$this->flashSuccess('action.update.success');
} catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
return $this->redirectToRoute('system_configuration');
}
$configSettings = $this->getInitializedConfigurations();
$configurations = [];
foreach ($configSettings as $configModel) {
if ($section !== $configModel->getSection()) {
$form2 = $this->createConfigurationsForm($configModel);
} else {
$form2 = $form;
}
$configurations[] = [
'model' => $configModel,
'form' => $form2->createView(),
];
}
return $this->render('system-configuration/index.html.twig', [
'sections' => $configurations,
]);
}
/**
* @param SystemConfigurationModel $configuration
* @return \Symfony\Component\Form\FormInterface
*/
private function createConfigurationsForm(SystemConfigurationModel $configuration)
{
return $this->createForm(
SystemConfigurationForm::class,
$configuration,
[
'attr' => ['id' => 'system_configuration_form_' . $configuration->getSection()],
'action' => $this->generateUrl('system_configuration_' . $configuration->getSection()),
'method' => 'POST'
]
);
}
/**
* @return SystemConfigurationModel[]
*/
protected function getInitializedConfigurations()
{
$types = $this->getConfigurationTypes();
$event = new SystemConfigurationEvent($types);
$this->eventDispatcher->dispatch(SystemConfigurationEvent::CONFIGURE, $event);
foreach ($event->getConfigurations() as $configs) {
foreach ($configs->getConfiguration() as $config) {
$config->setValue($this->configurations->find($config->getName()));
}
}
return $event->getConfigurations();
}
/**
* @return SystemConfigurationModel[]
*/
protected function getConfigurationTypes()
{
return [
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_TIMESHEET)
->setConfiguration([
(new Configuration())
->setName('timesheet.markdown_content')
->setType(CheckboxType::class)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('timesheet.duration_only')
->setType(CheckboxType::class)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('timesheet.rules.allow_future_times')
->setType(CheckboxType::class)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('timesheet.active_entries.hard_limit')
->setType(IntegerType::class)
->setTranslationDomain('system-configuration')
->setConstraints([
new GreaterThanOrEqual(['value' => 1])
]),
(new Configuration())
->setName('timesheet.active_entries.soft_limit')
->setType(IntegerType::class)
->setTranslationDomain('system-configuration')
->setConstraints([
new GreaterThanOrEqual(['value' => 1])
]),
]),
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_FORM_CUSTOMER)
->setConfiguration([
(new Configuration())
->setName('defaults.customer.timezone')
->setLabel('timezone')
->setType(TimezoneType::class),
(new Configuration())
->setName('defaults.customer.country')
->setLabel('country')
->setType(CountryType::class),
(new Configuration())
->setName('defaults.customer.currency')
->setLabel('currency')
->setType(CurrencyType::class),
]),
];
}
}

View File

@@ -69,6 +69,7 @@ class TimesheetController extends AbstractController
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
'showSummary' => $this->getUser()->getPreferenceValue('timesheet.daily_stats', false),
'duration_only' => $this->configuration->isDurationOnly(),
]);
}
@@ -124,7 +125,10 @@ class TimesheetController extends AbstractController
return $this->render(
'navbar/active-entries.html.twig',
['entries' => $activeEntries]
[
'entries' => $activeEntries,
'soft_limit' => $this->getSoftLimit(),
]
);
}

View File

@@ -9,6 +9,7 @@
namespace App\Controller;
use App\Configuration\TimesheetConfiguration;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Repository\TimesheetRepository;
@@ -23,32 +24,23 @@ use Symfony\Component\HttpFoundation\Response;
*/
trait TimesheetControllerTrait
{
/**
* @var int
*/
private $hardLimit = 1;
/**
* @var UserDateTimeFactory
*/
protected $dateTime;
/**
* @var TimesheetConfiguration
*/
protected $configuration;
/**
* @param UserDateTimeFactory $dateTime
* @param int $hardLimit
* @param TimesheetConfiguration $configuration
*/
public function __construct(UserDateTimeFactory $dateTime, int $hardLimit)
public function __construct(UserDateTimeFactory $dateTime, TimesheetConfiguration $configuration)
{
$this->dateTime = $dateTime;
$this->setHardLimit($hardLimit);
}
/**
* @param int $hardLimit
*/
protected function setHardLimit(int $hardLimit)
{
$this->hardLimit = $hardLimit;
$this->configuration = $configuration;
}
/**
@@ -56,7 +48,15 @@ trait TimesheetControllerTrait
*/
protected function getHardLimit()
{
return $this->hardLimit;
return $this->configuration->getActiveEntriesHardLimit();
}
/**
* @return int
*/
protected function getSoftLimit()
{
return $this->configuration->getActiveEntriesSoftLimit();
}
/**

View File

@@ -65,6 +65,7 @@ class TimesheetTeamController extends AbstractController
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
'duration_only' => $this->configuration->isDurationOnly(),
]);
}

View File

@@ -11,13 +11,12 @@ namespace App\DependencyInjection;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
/**
* This class that loads and manages the Kimai configuration and container parameter.
*/
class AppExtension extends Extension implements PrependExtensionInterface
class AppExtension extends Extension
{
/**
* @param array $configs
@@ -34,8 +33,8 @@ class AppExtension extends Extension implements PrependExtensionInterface
}
// safe alternatives to %kernel.project_dir%
$container->setParameter('kimai.data_dir', $container->getParameter('kernel.project_dir') . '/var/data');
$container->setParameter('kimai.plugin_dir', $container->getParameter('kernel.project_dir') . '/var/plugins');
$container->setParameter('kimai.data_dir', $config['data_dir']);
$container->setParameter('kimai.plugin_dir', $config['plugin_dir']);
$container->setParameter('kimai.languages', $config['languages']);
$container->setParameter('kimai.calendar', $config['calendar']);
@@ -47,7 +46,12 @@ class AppExtension extends Extension implements PrependExtensionInterface
$this->createPermissionParameter($config['permissions'], $container);
$this->createThemeParameter($config['theme'], $container);
$this->createUserParameter($config['user'], $container);
$this->createTimesheetParameter($config['timesheet'], $container);
$container->setParameter('kimai.config', $config);
$container->setParameter('kimai.timesheet', $config['timesheet']);
$container->setParameter('kimai.timesheet.rates', $config['timesheet']['rates']);
$container->setParameter('kimai.timesheet.rounding', $config['timesheet']['rounding']);
}
/**
@@ -112,47 +116,6 @@ class AppExtension extends Extension implements PrependExtensionInterface
$container->setParameter('kimai.fosuser', $config);
}
/**
* @param array $config
* @param ContainerBuilder $container
*/
protected function createTimesheetParameter(array $config, ContainerBuilder $container)
{
$container->setParameter('kimai.timesheet.rules', $config['rules']);
$container->setParameter('kimai.timesheet.rates', $config['rates']);
$container->setParameter('kimai.timesheet.rounding', $config['rounding']);
$container->setParameter('kimai.timesheet.duration_only', $config['duration_only']);
$container->setParameter('kimai.timesheet.markdown', $config['markdown_content']);
$container->setParameter('kimai.timesheet.active_entries.soft_limit', $config['active_entries']['soft_limit']);
$container->setParameter('kimai.timesheet.active_entries.hard_limit', $config['active_entries']['hard_limit']);
}
/**
* @param ContainerBuilder $container
*/
public function prepend(ContainerBuilder $container)
{
/*
$configuration = new Configuration();
$configs = $container->getExtensionConfig($this->getAlias());
try {
$config = $this->processConfiguration($configuration, $configs);
} catch (InvalidConfigurationException $e) {
trigger_error('Found invalid "kimai" configuration: ' . $e->getMessage());
$config = [];
}
$container->prependExtensionConfig(
'twig',
[
'globals' => [
'duration_only' => $config['timesheet']['duration_only'],
],
]
);
*/
}
/**
* @return string
*/

View File

@@ -25,12 +25,8 @@ class TwigContextCompilerPass implements CompilerPassInterface
{
$twig = $container->getDefinition('twig');
$theme = $container->getParameter('kimai.theme');
$durationOnly = $container->getParameter('kimai.timesheet.duration_only');
$twig->addMethodCall('addGlobal', ['kimai_context', array_merge($theme, [
'active_warning' => $container->getParameter('kimai.timesheet.active_entries.soft_limit')
])]);
$twig->addMethodCall('addGlobal', ['duration_only', $durationOnly]);
$twig->addMethodCall('addGlobal', ['kimai_context', $theme]);
if ($container->hasDefinition('twig.loader.native_filesystem')) {
$definition = $container->getDefinition('twig.loader.native_filesystem');

View File

@@ -32,6 +32,24 @@ class Configuration implements ConfigurationInterface
$rootNode
->children()
->scalarNode('data_dir')
->isRequired()
->validate()
->ifTrue(function ($value) {
return !file_exists($value);
})
->thenInvalid('Data directory does not exist')
->end()
->end()
->scalarNode('plugin_dir')
->isRequired()
->validate()
->ifTrue(function ($value) {
return !file_exists($value);
})
->thenInvalid('Plugin directory does not exist')
->end()
->end()
->append($this->getUserNode())
->append($this->getTimesheetNode())
->append($this->getInvoiceNode())
@@ -85,7 +103,6 @@ class Configuration implements ConfigurationInterface
->scalarNode('mode')
->defaultValue('default')
->validate()
->thenInvalid('Chosen rounding mode is invalid')
->ifTrue(function ($value) {
$class = 'App\\Timesheet\\Rounding\\' . ucfirst($value) . 'Rounding';
if (class_exists($class)) {

View File

@@ -0,0 +1,99 @@
<?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\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity(repositoryClass="App\Repository\ConfigurationRepository")
* @ORM\Table(
* name="configuration",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"name"})
* }
* )
* @UniqueEntity("name")
*/
class Configuration
{
/**
* @var int
*
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(name="id", type="integer")
*/
private $id;
/**
* @var string
*
* @ORM\Column(name="name", type="string", length=100, nullable=false)
* @Assert\Length(min=2, max=100)
*/
private $name;
/**
* @var string
*
* @ORM\Column(name="value", type="string", length=255, nullable=true)
*/
private $value;
/**
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* @return string
*/
public function getName(): ?string
{
return $this->name;
}
/**
* @param string $name
* @return Configuration
*/
public function setName(string $name): Configuration
{
$this->name = $name;
return $this;
}
/**
* Given $value will not be serialized before its stored, so it should be a scalar type.
*
* @param mixed $value
* @return Configuration
*/
public function setValue($value): Configuration
{
$this->value = $value;
return $this;
}
/**
* @return string
*/
public function getValue(): ?string
{
return $this->value;
}
}

View File

@@ -0,0 +1,53 @@
<?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\Event;
use App\Form\Model\SystemConfiguration;
use Symfony\Component\EventDispatcher\Event;
/**
* This event should be used, if system configurations should be changed/added dynamically.
*/
class SystemConfigurationEvent extends Event
{
public const CONFIGURE = 'app.system_configuration';
/**
* @var SystemConfiguration[]
*/
protected $preferences;
/**
* @param SystemConfiguration[] $configurations
*/
public function __construct(array $configurations)
{
$this->preferences = $configurations;
}
/**
* @return SystemConfiguration[]
*/
public function getConfigurations()
{
return $this->preferences;
}
/**
* @param SystemConfiguration $configuration
* @return SystemConfigurationEvent
*/
public function addConfiguration(SystemConfiguration $configuration): SystemConfigurationEvent
{
$this->preferences[] = $configuration;
return $this;
}
}

View File

@@ -120,5 +120,11 @@ class MenuSubscriber implements EventSubscriberInterface
new MenuItemModel('activity_admin', 'menu.admin_activity', 'admin_activity', [], 'fas fa-tasks')
);
}
if ($auth->isGranted('system_configuration')) {
$menu->addChild(
new MenuItemModel('system_configuration', 'menu.system_configuration', 'system_configuration', [], 'fas fa-cogs')
);
}
}
}

View File

@@ -0,0 +1,177 @@
<?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\Form\Model;
use Symfony\Component\Validator\Constraint;
class Configuration
{
/**
* @var string
*/
private $name;
/**
* @var string|null
*/
private $label;
/**
* @var string
*/
private $translationDomain = 'messages';
/**
* @var mixed
*/
private $value;
/**
* @var string
*/
protected $type;
/**
* @var bool
*/
protected $enabled = true;
/**
* @var Constraint[]
*/
protected $constraints = [];
/**
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* @param string $name
* @return Configuration
*/
public function setName(string $name)
{
$this->name = $name;
return $this;
}
/**
* @return mixed
*/
public function getValue()
{
return $this->value;
}
/**
* @param mixed $value
* @return Configuration
*/
public function setValue($value)
{
$this->value = $value;
return $this;
}
/**
* @return string
*/
public function getType(): string
{
return $this->type;
}
/**
* @param string $type
* @return Configuration
*/
public function setType(string $type)
{
$this->type = $type;
return $this;
}
/**
* @return bool
*/
public function isEnabled(): bool
{
return $this->enabled;
}
/**
* @param bool $enabled
* @return Configuration
*/
public function setEnabled(bool $enabled)
{
$this->enabled = $enabled;
return $this;
}
/**
* @return string
*/
public function getLabel()
{
return $this->label;
}
/**
* @param string $label
* @return Configuration
*/
public function setLabel(string $label)
{
$this->label = $label;
return $this;
}
/**
* @return Constraint[]
*/
public function getConstraints(): array
{
return $this->constraints;
}
/**
* @param Constraint[] $constraints
* @return Configuration
*/
public function setConstraints(array $constraints)
{
$this->constraints = $constraints;
return $this;
}
/**
* @return string
*/
public function getTranslationDomain()
{
return $this->translationDomain;
}
/**
* @param string $translationDomain
* @return Configuration
*/
public function setTranslationDomain(string $translationDomain)
{
$this->translationDomain = $translationDomain;
return $this;
}
}

View File

@@ -0,0 +1,63 @@
<?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\Form\Model;
class SystemConfiguration
{
public const SECTION_TIMESHEET = 'timesheet';
public const SECTION_FORM_CUSTOMER = 'form_customer';
/**
* @var string
*/
private $section;
/**
* @var Configuration[]
*/
private $configuration;
/**
* @return string
*/
public function getSection(): string
{
return $this->section;
}
/**
* @param string $section
* @return SystemConfiguration
*/
public function setSection(string $section)
{
$this->section = $section;
return $this;
}
/**
* @return Configuration[]
*/
public function getConfiguration(): array
{
return $this->configuration;
}
/**
* @param Configuration[] $configuration
* @return SystemConfiguration
*/
public function setConfiguration(array $configuration)
{
$this->configuration = $configuration;
return $this;
}
}

View File

@@ -0,0 +1,50 @@
<?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\Form;
use App\Form\Model\SystemConfiguration;
use App\Form\Type\SystemConfigurationType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Defines the form used to edit one section within the system configuration.
*/
class SystemConfigurationForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('configuration', CollectionType::class, [
'entry_type' => SystemConfigurationType::class,
'entry_options' => ['label' => false],
'allow_add' => false,
'allow_delete' => false,
'label' => false,
]);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => SystemConfiguration::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'edit_user_preferences',
]);
}
}

View File

@@ -9,6 +9,7 @@
namespace App\Form;
use App\Configuration\TimesheetConfiguration;
use App\Entity\Timesheet;
use App\Form\Type\ActivityType;
use App\Form\Type\CustomerType;
@@ -38,34 +39,31 @@ class TimesheetEditForm extends AbstractType
* @var CustomerRepository
*/
private $customers;
/**
* @var ProjectRepository
*/
private $projects;
/**
* @var bool
*/
private $durationOnly = false;
/**
* @var UserDateTimeFactory
*/
protected $dateTime;
/**
* @var TimesheetConfiguration
*/
private $configuration;
/**
* @param CustomerRepository $customer
* @param ProjectRepository $project
* @param UserDateTimeFactory $dateTime
* @param bool $durationOnly
* @param TimesheetConfiguration $config
*/
public function __construct(CustomerRepository $customer, ProjectRepository $project, UserDateTimeFactory $dateTime, bool $durationOnly)
public function __construct(CustomerRepository $customer, ProjectRepository $project, UserDateTimeFactory $dateTime, TimesheetConfiguration $config)
{
$this->customers = $customer;
$this->projects = $project;
$this->dateTime = $dateTime;
$this->durationOnly = $durationOnly;
$this->configuration = $config;
}
/**
@@ -106,7 +104,7 @@ class TimesheetEditForm extends AbstractType
$timezone = $begin->getTimezone()->getName();
}
if (null === $end || !$options['duration_only']) {
if (null === $end || !$this->configuration->isDurationOnly()) {
$builder->add('begin', DateTimePickerType::class, [
'label' => 'label.begin',
'model_timezone' => $timezone,
@@ -114,7 +112,7 @@ class TimesheetEditForm extends AbstractType
]);
}
if ($options['duration_only']) {
if ($this->configuration->isDurationOnly()) {
$builder->add('duration', DurationType::class, [
'required' => false,
'docu_chapter' => 'timesheet.html#duration-format',
@@ -303,7 +301,6 @@ class TimesheetEditForm extends AbstractType
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'timesheet_edit',
'duration_only' => $this->durationOnly,
'include_user' => false,
'include_exported' => false,
'include_rate' => true,

View File

@@ -71,6 +71,7 @@ class DateTimePickerType extends AbstractType
if ($options['autofocus']) {
$values['autofocus'] = 'autofocus';
}
return $values;
});
}

View File

@@ -0,0 +1,75 @@
<?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\Form\Type;
use App\Form\Model\Configuration;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to edit a system configuration.
*/
class SystemConfigurationType extends AbstractType
{
/**
* @param FormBuilderInterface $builder
* @param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (FormEvent $event) {
/** @var Configuration $preference */
$preference = $event->getData();
if (!($preference instanceof Configuration)) {
return;
}
// prevents unconfigured values from showing up in the form
if ($preference->getType() === null) {
return;
}
$required = true;
if (CheckboxType::class == $preference->getType()) {
$required = false;
}
$type = $preference->getType();
if (!$preference->isEnabled()) {
$type = HiddenType::class;
}
$event->getForm()->add('value', $type, [
'label' => 'label.' . ($preference->getLabel() ?? $preference->getName()),
'constraints' => $preference->getConstraints(),
'required' => $required,
'disabled' => !$preference->isEnabled(),
'translation_domain' => $preference->getTranslationDomain(),
]);
}
);
$builder->add('name', HiddenType::class);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Configuration::class,
]);
}
}

View File

@@ -135,6 +135,10 @@ class Kernel extends BaseKernel
// load plugin routes
$pluginsDir = $this->getProjectDir() . '/var/plugins';
if (!file_exists($pluginsDir)) {
return;
}
$routes->import($pluginsDir . '/*Bundle/Resources/config/routes' . self::CONFIG_EXTS, '/', 'glob');
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/*
* 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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Create the system configuration table.
*
* @version 0.9
*/
final class Version20190321181243 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
if ($this->isPlatformSqlite()) {
$this->addSql('CREATE TABLE kimai2_configuration (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(100) NOT NULL, value VARCHAR(255) DEFAULT NULL)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_1C5D63D85E237E06 ON kimai2_configuration (name)');
} else {
$this->addSql('CREATE TABLE kimai2_configuration (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(100) NOT NULL, value VARCHAR(255) DEFAULT NULL, UNIQUE INDEX UNIQ_1C5D63D85E237E06 (name), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
}
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE kimai2_configuration');
}
}

View File

@@ -0,0 +1,66 @@
<?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\Repository;
use App\Configuration\ConfigLoaderInterface;
use App\Entity\Configuration;
use App\Form\Model\SystemConfiguration;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query;
class ConfigurationRepository extends AbstractRepository implements ConfigLoaderInterface
{
/**
* @param string $prefix
* @return Configuration[]
*/
public function getConfiguration(?string $prefix = null): array
{
if (null === $prefix) {
return $this->findAll();
}
$qb = $this->createQueryBuilder('c');
$qb
->select('c')
->where($qb->expr()->like('c.name', ':prefix'))
->setParameter(':prefix', $prefix . '%');
return $qb->getQuery()->getResult(Query::HYDRATE_OBJECT);
}
public function saveSystemConfiguration(SystemConfiguration $model)
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
foreach ($model->getConfiguration() as $configuration) {
$entity = $this->findOneBy(['name' => $configuration->getName()]);
$value = $configuration->getValue();
if (null === $entity) {
$entity = new Configuration();
$entity->setName($configuration->getName());
}
$entity->setValue($value);
$em->persist($entity);
}
$em->flush();
$em->commit();
} catch (ORMException $ex) {
$em->rollback();
throw $ex;
}
}
}

View File

@@ -9,6 +9,7 @@
namespace App\Twig;
use App\Configuration\TimesheetConfiguration;
use App\Utils\Markdown;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
@@ -23,18 +24,18 @@ class MarkdownExtension extends AbstractExtension
*/
private $markdown;
/**
* @var bool
* @var TimesheetConfiguration
*/
private $timesheetIsMarkdown = false;
protected $configuration;
/**
* MarkdownExtension constructor.
* @param Markdown $parser
*/
public function __construct(Markdown $parser, bool $timesheetAsMarkdown = false)
public function __construct(Markdown $parser, TimesheetConfiguration $configuration)
{
$this->markdown = $parser;
$this->timesheetIsMarkdown = $timesheetAsMarkdown;
$this->configuration = $configuration;
}
/**
@@ -60,7 +61,7 @@ class MarkdownExtension extends AbstractExtension
return '';
}
if ($this->timesheetIsMarkdown) {
if ($this->configuration->isMarkdownEnabled()) {
return $this->markdown->toHtml($content, false);
}

View File

@@ -9,6 +9,7 @@
namespace App\Validator\Constraints;
use App\Configuration\TimesheetConfiguration;
use App\Entity\Timesheet as TimesheetEntity;
use App\Validator\Constraints\Timesheet as TimesheetConstraint;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
@@ -24,38 +25,18 @@ class TimesheetValidator extends ConstraintValidator
*/
protected $auth;
/**
* @var array
* @var TimesheetConfiguration
*/
protected $rules = [];
/**
* @var bool
*/
protected $durationOnly = false;
protected $configuration;
/**
* @param AuthorizationCheckerInterface $auth
* @param array $ruleset
* @param bool $durationOnly
* @param TimesheetConfiguration $configuration
*/
public function __construct(AuthorizationCheckerInterface $auth, array $ruleset, bool $durationOnly)
public function __construct(AuthorizationCheckerInterface $auth, TimesheetConfiguration $configuration)
{
$this->auth = $auth;
$this->rules = $ruleset;
$this->durationOnly = $durationOnly;
}
/**
* @param string $key
* @param null $default
* @return mixed|null
*/
protected function getRule(string $key, $default = null)
{
if (!isset($this->rules[$key])) {
return $default;
}
return $this->rules[$key];
$this->configuration = $configuration;
}
/**
@@ -89,7 +70,7 @@ class TimesheetValidator extends ConstraintValidator
if ($context->getViolations()->count() == 0 && null === $timesheet->getEnd()) {
if (!$this->auth->isGranted('start', $timesheet)) {
$context->buildViolation('You are not allowed to start this timesheet record.')
->atPath($this->durationOnly ? 'duration' : 'end')
->atPath($this->configuration->isDurationOnly() ? 'duration' : 'end')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::START_DISALLOWED)
->addViolation();
@@ -125,7 +106,7 @@ class TimesheetValidator extends ConstraintValidator
->addViolation();
}
if (false === $this->getRule('allow_future_times', true) && time() < $timesheet->getBegin()->getTimestamp()) {
if (false === $this->configuration->isAllowFutureTimes() && time() < $timesheet->getBegin()->getTimestamp()) {
$context->buildViolation('The begin date cannot be in the future.')
->atPath('begin')
->setTranslationDomain('validators')