added plugin screen (#671)

This commit is contained in:
Kevin Papst
2019-04-08 18:38:56 +02:00
committed by GitHub
parent 5bea9915f5
commit 3a4aa5a001
68 changed files with 1392 additions and 490 deletions

View File

@@ -27,8 +27,13 @@ Remember to execute the necessary timezone conversion script, if you haven't upd
**BC BREAKS**
- in an ongoing effort to simplify future installation and upgrade processes the `.env` variable `DATABASE_PREFIX` was removed.
The table prefix is now hardcoded to `kimai2_`. If you used another prefix, you have to rename your tables manually
before starting the update process. You can also delete the row `DATABASE_PREFIX` from your `.env` file.
before starting the update process. And delete the row `DATABASE_PREFIX` from your `.env` file.
- API: DateTime objects will be returned including timezone identifier (previously 2019-03-02 14:23 - now 2019-03-02T14:23:00+00:00)
- Plugin mechanism changed: existing Plugins have to be deleted or updated
**Check if you want to apply changes to your `local.yaml`:**
- New permissions are available: `system_configuration`, `system_actions`, `plugins`
**Check if you want to apply changes to your `local.yaml`:**

View File

@@ -1,6 +1,8 @@
/*
/*!
* This file is part of the Kimai time-tracking app.
*
* Main JS application file for Kimai 2. This file should be included in all pages.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

View File

@@ -3,6 +3,12 @@
"license": "MIT",
"type": "project",
"description": "Kimai Time-Tracking 2",
"authors": [
{
"name": "Kevin Papst",
"homepage": "https://www.kevinpapst.de"
}
],
"require": {
"php": "^7.1.3",
"ext-gd": "*",
@@ -56,7 +62,6 @@
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.10",
"phpunit/phpunit": "^7.0",
"squizlabs/php_codesniffer": "^3.2",
"symfony/browser-kit": "^4.0",
"symfony/css-selector": "^4.0",
"symfony/phpunit-bridge": "^4.0",

459
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,10 @@ parameters:
doctrine:
dbal:
# configure these for your database server
default_connection: default
connections:
default:
url: '%env(resolve:DATABASE_URL)%'
driver: 'pdo_mysql'
server_version: '5.7'
charset: utf8mb4
@@ -15,24 +18,19 @@ doctrine:
charset: utf8mb4
collate: utf8mb4_unicode_ci
# With Symfony 3.3, remove the `resolve:` prefix
url: '%env(resolve:DATABASE_URL)%'
types:
datetime: App\Doctrine\UTCDateTimeType
orm:
auto_generate_proxy_classes: '%kernel.debug%'
default_entity_manager: default
entity_managers:
default:
connection: default
naming_strategy: doctrine.orm.naming_strategy.underscore
auto_mapping: true
mappings:
App:
is_bundle: false
type: annotation
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: Kimai
loggable:
type: annotation
alias: Gedmo
prefix: Gedmo\Loggable\Entity
dir: "%kernel.project_dir%/vendor/gedmo/doctrine-extensions/lib/Gedmo/Loggable/Entity"

View File

@@ -97,7 +97,7 @@ kimai:
ROLE_USER: []
ROLE_TEAMLEAD: [view_invoice_template,create_invoice_template,edit_invoice_template,view_rate_own_timesheet,view_rate_other_timesheet,hourly-rate_own_profile]
ROLE_ADMIN: [hourly-rate_own_profile]
ROLE_SUPER_ADMIN: [hourly-rate_own_profile,hourly-rate_other_profile,delete_own_profile,roles_own_profile,system_information,system_configuration]
ROLE_SUPER_ADMIN: [hourly-rate_own_profile,hourly-rate_other_profile,delete_own_profile,roles_own_profile,system_information,system_actions,system_configuration,plugins]
# --------------------------------------------------------------------------------
# Language specific settings, like the date formats

View File

@@ -36,6 +36,10 @@ security:
path: fos_user_security_logout
target: homepage
access_decision_manager:
strategy: unanimous
allow_if_all_abstain: false
role_hierarchy:
ROLE_USER: ROLE_CUSTOMER
ROLE_TEAMLEAD: ROLE_USER

View File

@@ -64,16 +64,16 @@ services:
App\Utils\MPdfConverter:
arguments: ['%kernel.cache_dir%']
App\Plugin\PluginManager:
arguments: [!tagged kimai.plugin]
App\Export\ServiceExport:
arguments: [!tagged export.renderer]
# ================================================================================
# DATABASE
# ================================================================================
# service that prefixes every database table
App\Doctrine\TablePrefixSubscriber:
class: App\Doctrine\TablePrefixSubscriber
tags:
- { name: doctrine.event_subscriber }
# updates timesheet records and apply configured rate & rounding rules
App\Doctrine\TimesheetSubscriber:
class: App\Doctrine\TimesheetSubscriber

View File

@@ -11,6 +11,10 @@ namespace App\Controller;
use App\Constants;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Annotation\Route;
/**
@@ -40,6 +44,11 @@ class AboutController extends AbstractController
* @return \Symfony\Component\HttpFoundation\Response
*/
public function indexAction()
{
return $this->getAboutView();
}
protected function getAboutView(array $additional = [])
{
$phpInfo = $this->getPhpInfo();
unset($phpInfo[0]);
@@ -71,7 +80,8 @@ class AboutController extends AbstractController
}
}
return $this->render('about/system.html.twig', [
return $this->render('about/system.html.twig', array_merge(
[
'modules' => get_loaded_extensions(),
'dotenv' => [
'APP_ENV' => getenv('APP_ENV'),
@@ -81,7 +91,9 @@ class AboutController extends AbstractController
'info' => $phpInfo,
'settings' => $settings,
'license' => $this->getLicense(),
]);
],
$additional
));
}
/**
@@ -141,4 +153,26 @@ class AboutController extends AbstractController
return $phpinfo['phpinfo'];
}
/**
* @Route(path="/flush-cache", name="system_flush_cache", methods={"GET"})
*
* @Security("is_granted('system_actions')")
*/
public function rebuildContainer(KernelInterface $kernel)
{
$application = new Application($kernel);
$application->setAutoExit(false);
$input = new ArrayInput([
'command' => 'cache:clear',
'--env' => $kernel->getEnvironment(),
'-n',
]);
$output = new BufferedOutput();
$application->run($input, $output);
return $this->getAboutView(['content_action' => $output->fetch()]);
}
}

View File

@@ -0,0 +1,51 @@
<?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\Plugin\PluginManager;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/admin/plugins")
* @Security("is_granted('plugins')")
*/
class PluginController extends AbstractController
{
/**
* @var PluginManager
*/
protected $plugins;
/**
* @param PluginManager $manager
*/
public function __construct(PluginManager $manager)
{
$this->plugins = $manager;
}
/**
* @Route(path="/", name="plugins", methods={"GET"})
*
* @return \Symfony\Component\HttpFoundation\Response
*/
public function indexAction()
{
$plugins = $this->plugins->getPlugins();
foreach ($this->plugins->getPlugins() as $plugin) {
$this->plugins->loadMetadata($plugin);
}
return $this->render('plugin/index.html.twig', [
'plugins' => $plugins,
]);
}
}

View File

@@ -27,11 +27,10 @@ class DoctrineCompilerPass implements CompilerPassInterface
];
/**
* @param ContainerBuilder $container
* @return array|false|null|string
* @throws \Exception
*/
protected function findEngine(ContainerBuilder $container)
protected function findEngine()
{
$engine = null;
@@ -47,7 +46,7 @@ class DoctrineCompilerPass implements CompilerPassInterface
if (null === $engine) {
throw new \Exception(
'Could not detect database engine. Please set the environment config DATABASE_ENGINE ' .
'to one of: "' . implode(', ', $this->allowedEngines) . '" in your .env file: DATABASE_ENGINE=sqlite'
'to one of: "' . implode(', ', $this->allowedEngines) . '" in your .env file, e.g. DATABASE_ENGINE=sqlite'
);
}
@@ -68,7 +67,7 @@ class DoctrineCompilerPass implements CompilerPassInterface
*/
protected function getConfigFile(ContainerBuilder $container)
{
$engine = $this->findEngine($container);
$engine = $this->findEngine();
$configDir = realpath(
$container->getParameter('kernel.project_dir') . '/vendor/beberlei/DoctrineExtensions/config/'

View File

@@ -1,41 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\DependencyInjection\Compiler;
use App\Export\ServiceExport;
use App\Kernel;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
/**
* Dynamically adds all dependencies to the ExportService.
*/
class ExportServiceCompilerPass implements CompilerPassInterface
{
/**
* @param ContainerBuilder $container
* @throws \Exception
*/
public function process(ContainerBuilder $container)
{
// always first check if the primary service is defined
if (!$container->has(ServiceExport::class)) {
return;
}
$definition = $container->findDefinition(ServiceExport::class);
$taggedRenderer = $container->findTaggedServiceIds(Kernel::TAG_EXPORT_RENDERER);
foreach ($taggedRenderer as $id => $tags) {
$definition->addMethodCall('addRenderer', [new Reference($id)]);
}
}
}

View File

@@ -45,10 +45,11 @@ abstract class AbstractMigration extends BaseAbstractMigration implements Contai
/**
* @param string $name
* @return string
* @deprecated since 0.9
*/
protected function getTableName($name)
{
return TablePrefixSubscriber::PREFIX . $name;
return 'kimai2_' . $name;
}
/**

View File

@@ -1,56 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Doctrine;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\LoadClassMetadataEventArgs;
use Doctrine\ORM\Events;
/**
* Adds a prefix to every doctrine entity AKA database table
*/
class TablePrefixSubscriber implements EventSubscriber
{
public const PREFIX = 'kimai2_';
/**
* @return array|string[]
*/
public function getSubscribedEvents()
{
return [
Events::loadClassMetadata,
];
}
/**
* @param LoadClassMetadataEventArgs $args
*/
public function loadClassMetadata(LoadClassMetadataEventArgs $args)
{
$classMetadata = $args->getClassMetadata();
if ($classMetadata->isInheritanceTypeSingleTable() && !$classMetadata->isRootEntity()) {
// if we are in an inheritance hierarchy, only apply this once
return;
}
$classMetadata->setPrimaryTable(['name' => self::PREFIX . $classMetadata->getTableName()]);
foreach ($classMetadata->getAssociationMappings() as $fieldName => $mapping) {
if (\Doctrine\ORM\Mapping\ClassMetadataInfo::MANY_TO_MANY == $mapping['type']
// Check if "joinTable" exists:
// it can be null if this field is the reverse side of a ManyToMany relationship
&& array_key_exists('name', $classMetadata->associationMappings[$fieldName]['joinTable'])) {
$mappedTableName = $classMetadata->associationMappings[$fieldName]['joinTable']['name'];
$classMetadata->associationMappings[$fieldName]['joinTable']['name'] = self::PREFIX . $mappedTableName;
}
}
}
}

View File

@@ -13,7 +13,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(name="activities")
* @ORM\Table(name="kimai2_activities")
* @ORM\Entity(repositoryClass="App\Repository\ActivityRepository")
*/
class Activity

View File

@@ -15,8 +15,7 @@ use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity(repositoryClass="App\Repository\ConfigurationRepository")
* @ORM\Table(
* name="configuration",
* @ORM\Table(name="kimai2_configuration",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"name"})
* }

View File

@@ -13,7 +13,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(name="customers")
* @ORM\Table(name="kimai2_customers")
* @ORM\Entity(repositoryClass="App\Repository\CustomerRepository")
*/
class Customer

View File

@@ -13,11 +13,8 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* InvoiceTemplate
*
* @ORM\Entity(repositoryClass="App\Repository\InvoiceTemplateRepository")
* @ORM\Table(
* name="invoice_templates",
* @ORM\Table(name="kimai2_invoice_templates",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"name"})
* }

View File

@@ -13,7 +13,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(name="projects")
* @ORM\Table(name="kimai2_projects")
* @ORM\Entity(repositoryClass="App\Repository\ProjectRepository")
*/
class Project

View File

@@ -13,8 +13,7 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(
* name="timesheet",
* @ORM\Table(name="kimai2_timesheet",
* indexes={
* @ORM\Index(columns={"user"}),
* @ORM\Index(columns={"activity_id"})

View File

@@ -18,11 +18,8 @@ use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Application main User entity.
*
* @ORM\Entity(repositoryClass="App\Repository\UserRepository")
* @ORM\Table(
* name="users",
* @ORM\Table(name="kimai2_users",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"username"}),
* @ORM\UniqueConstraint(columns={"email"})

View File

@@ -16,11 +16,8 @@ use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints as Assert;
/**
* UserPreference
*
* @ORM\Entity()
* @ORM\Table(
* name="user_preferences",
* @ORM\Table(name="kimai2_user_preferences",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"user_id", "name"})
* }

View File

@@ -10,6 +10,7 @@
namespace App\Event;
use KevinPapst\AdminLTEBundle\Event\SidebarMenuEvent;
use KevinPapst\AdminLTEBundle\Model\MenuItemModel;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Component\HttpFoundation\Request;
@@ -28,17 +29,27 @@ class ConfigureMainMenuEvent extends Event
* @var SidebarMenuEvent
*/
private $event;
/**
* @var MenuItemModel
*/
private $admin;
/**
* @var MenuItemModel
*/
private $system;
/**
* @param Request $request
* @param SidebarMenuEvent $event
* @param MenuItemModel $admin
* @param MenuItemModel $system
*/
public function __construct(
Request $request,
SidebarMenuEvent $event
) {
public function __construct(Request $request, SidebarMenuEvent $event, MenuItemModel $admin, MenuItemModel $system)
{
$this->request = $request;
$this->event = $event;
$this->admin = $admin;
$this->system = $system;
}
/**
@@ -56,4 +67,20 @@ class ConfigureMainMenuEvent extends Event
{
return $this->event;
}
/**
* @return MenuItemModel
*/
public function getAdminMenu()
{
return $this->admin;
}
/**
* @return MenuItemModel
*/
public function getSystemMenu()
{
return $this->system;
}
}

View File

@@ -17,6 +17,10 @@ class ThemeEvent extends Event
public const JAVASCRIPT = 'app.theme.javascript';
public const STYLESHEET = 'app.theme.css';
public const HTML_HEAD = 'app.theme.html_head';
public const CONTENT_BEFORE = 'app.theme.content_before';
public const CONTENT_START = 'app.theme.content_start';
public const CONTENT_END = 'app.theme.content_end';
public const CONTENT_AFTER = 'app.theme.content_after';
/**
* @var User

View File

@@ -66,26 +66,36 @@ class MenuBuilderSubscriber implements EventSubscriberInterface
new MenuItemModel('dashboard', 'menu.homepage', 'dashboard', [], 'fas fa-tachometer-alt')
);
$this->eventDispatcher->dispatch(
ConfigureMainMenuEvent::CONFIGURE,
new ConfigureMainMenuEvent(
$menuEvent = new ConfigureMainMenuEvent(
$request,
$event
)
$event,
new MenuItemModel('admin', 'menu.admin', ''),
new MenuItemModel('system', 'menu.system', '')
);
$admin = new MenuItemModel('admin', 'menu.admin', '', [], 'fas fa-wrench');
$this->eventDispatcher->dispatch(ConfigureMainMenuEvent::CONFIGURE, $menuEvent);
// @deprecated since 0.9, will be removed with 1.0
$this->eventDispatcher->dispatch(
ConfigureAdminMenuEvent::CONFIGURE,
new ConfigureAdminMenuEvent(
$request,
$admin
$menuEvent->getAdminMenu()
)
);
if ($admin->hasChildren()) {
$event->addItem($admin);
if ($menuEvent->getAdminMenu()->hasChildren()) {
$event->addItem(new MenuItemModel('admin', 'menu.admin', ''));
foreach ($menuEvent->getAdminMenu()->getChildren() as $child) {
$event->addItem($child);
}
}
if ($menuEvent->getSystemMenu()->hasChildren()) {
$event->addItem(new MenuItemModel('system', 'menu.system', ''));
foreach ($menuEvent->getSystemMenu()->getChildren() as $child) {
$event->addItem($child);
}
}
$this->activateByRoute(

View File

@@ -9,15 +9,14 @@
namespace App\EventSubscriber;
use App\Event\ConfigureAdminMenuEvent;
use App\Event\ConfigureMainMenuEvent;
use KevinPapst\AdminLTEBundle\Event\SidebarMenuEvent;
use KevinPapst\AdminLTEBundle\Model\MenuItemModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* Menu event subscriber for timesheet, customer, projects, activities.
* This is a sample implementation for developer who want to add new navigation entries in their bundles.
* Menu event subscriber is creating the Kimai default menu structure.
*/
class MenuSubscriber implements EventSubscriberInterface
{
@@ -42,7 +41,6 @@ class MenuSubscriber implements EventSubscriberInterface
{
return [
ConfigureMainMenuEvent::CONFIGURE => ['onMainMenuConfigure', 100],
ConfigureAdminMenuEvent::CONFIGURE => ['onAdminMenuConfigure', 100],
];
}
@@ -57,7 +55,17 @@ class MenuSubscriber implements EventSubscriberInterface
return;
}
$menu = $event->getMenu();
$this->configureMainMenu($event->getMenu());
$this->configureAdminMenu($event->getAdminMenu());
$this->configureSystemMenu($event->getSystemMenu());
}
/**
* @param SidebarMenuEvent $menu
*/
protected function configureMainMenu(SidebarMenuEvent $menu)
{
$auth = $this->security;
if ($auth->isGranted('view_own_timesheet')) {
$menu->addItem(
@@ -67,7 +75,7 @@ class MenuSubscriber implements EventSubscriberInterface
if ($auth->isGranted('view_invoice')) {
$menu->addItem(
new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], 'fas fa-file-invoice')
new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], 'far fa-file-alt')
);
}
@@ -79,33 +87,21 @@ class MenuSubscriber implements EventSubscriberInterface
}
/**
* @param \App\Event\ConfigureAdminMenuEvent $event
* @param MenuItemModel $menu
*/
public function onAdminMenuConfigure(ConfigureAdminMenuEvent $event)
protected function configureAdminMenu(MenuItemModel $menu)
{
$auth = $this->security;
if (!$auth->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
return;
}
$menu = $event->getAdminMenu();
if ($auth->isGranted('view_other_timesheet')) {
$menu->addChild(
new MenuItemModel('timesheet_admin', 'menu.admin_timesheet', 'admin_timesheet', [], 'far fa-clock')
);
}
if ($auth->isGranted('view_user')) {
$menu->addChild(
new MenuItemModel('user_admin', 'menu.admin_user', 'admin_user', [], 'fas fa-user')
new MenuItemModel('timesheet_admin', 'menu.admin_timesheet', 'admin_timesheet', [], 'fas fa-user-clock')
);
}
if ($auth->isGranted('view_customer')) {
$menu->addChild(
new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], 'fas fa-users')
new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], 'fas fa-user-tie')
);
}
@@ -120,6 +116,26 @@ class MenuSubscriber implements EventSubscriberInterface
new MenuItemModel('activity_admin', 'menu.admin_activity', 'admin_activity', [], 'fas fa-tasks')
);
}
}
/**
* @param MenuItemModel $menu
*/
protected function configureSystemMenu(MenuItemModel $menu)
{
$auth = $this->security;
if ($auth->isGranted('view_user')) {
$menu->addChild(
new MenuItemModel('user_admin', 'menu.admin_user', 'admin_user', [], 'fas fa-users')
);
}
if ($auth->isGranted('plugins')) {
$menu->addChild(
new MenuItemModel('plugins', 'menu.plugin', 'plugins', [], 'fas fa-plug')
);
}
if ($auth->isGranted('system_configuration')) {
$menu->addChild(

View File

@@ -13,20 +13,21 @@ use App\Entity\Timesheet;
use App\Export\RendererInterface;
use App\Repository\Query\TimesheetQuery;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
class HtmlRenderer implements RendererInterface
{
use RendererTrait;
/**
* @var \Twig_Environment
* @var Environment
*/
protected $twig;
/**
* @param \Twig_Environment $twig
* @param Environment $twig
*/
public function __construct(\Twig_Environment $twig)
public function __construct(Environment $twig)
{
$this->twig = $twig;
}
@@ -35,9 +36,9 @@ class HtmlRenderer implements RendererInterface
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \Twig_Error_Loader
* @throws \Twig_Error_Runtime
* @throws \Twig_Error_Syntax
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{

View File

@@ -16,13 +16,14 @@ use App\Timesheet\UserDateTimeFactory;
use App\Utils\HtmlToPdfConverter;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Twig\Environment;
class PDFRenderer implements RendererInterface
{
use RendererTrait;
/**
* @var \Twig_Environment
* @var Environment
*/
protected $twig;
/**
@@ -35,10 +36,11 @@ class PDFRenderer implements RendererInterface
protected $converter;
/**
* @param \Twig_Environment $twig
* @param Environment $twig
* @param UserDateTimeFactory $dateTime
* @param HtmlToPdfConverter $converter
*/
public function __construct(\Twig_Environment $twig, UserDateTimeFactory $dateTime, HtmlToPdfConverter $converter)
public function __construct(Environment $twig, UserDateTimeFactory $dateTime, HtmlToPdfConverter $converter)
{
$this->twig = $twig;
$this->dateTime = $dateTime;
@@ -49,10 +51,9 @@ class PDFRenderer implements RendererInterface
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \Mpdf\MpdfException
* @throws \Twig_Error_Loader
* @throws \Twig_Error_Runtime
* @throws \Twig_Error_Syntax
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{

View File

@@ -16,6 +16,16 @@ class ServiceExport
*/
protected $renderer = [];
/**
* @param RendererInterface[] $renderer
*/
public function __construct(iterable $renderer)
{
foreach ($renderer as $render) {
$this->addRenderer($render);
}
}
/**
* @param RendererInterface $renderer
* @return $this

View File

@@ -13,18 +13,19 @@ use App\Entity\InvoiceDocument;
use App\Invoice\RendererInterface;
use App\Model\InvoiceModel;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
class TwigRenderer implements RendererInterface
{
/**
* @var \Twig_Environment
* @var Environment
*/
protected $twig;
/**
* @param \Twig_Environment $twig
* @param Environment $twig
*/
public function __construct(\Twig_Environment $twig)
public function __construct(Environment $twig)
{
$this->twig = $twig;
}

View File

@@ -11,13 +11,13 @@ namespace App;
use App\DependencyInjection\AppExtension;
use App\DependencyInjection\Compiler\DoctrineCompilerPass;
use App\DependencyInjection\Compiler\ExportServiceCompilerPass;
use App\DependencyInjection\Compiler\InvoiceServiceCompilerPass;
use App\DependencyInjection\Compiler\TwigContextCompilerPass;
use App\Export\RendererInterface as ExportRendererInterface;
use App\Invoice\CalculatorInterface as InvoiceCalculator;
use App\Invoice\NumberGeneratorInterface;
use App\Invoice\RendererInterface as InvoiceRendererInterface;
use App\Plugin\PluginInterface;
use App\Timesheet\CalculatorInterface as TimesheetCalculator;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\Config\Loader\LoaderInterface;
@@ -34,6 +34,7 @@ class Kernel extends BaseKernel
public const CONFIG_EXTS = '.{php,xml,yaml,yml}';
public const TAG_PLUGIN = 'kimai.plugin';
public const TAG_EXPORT_RENDERER = 'export.renderer';
public const TAG_INVOICE_RENDERER = 'invoice.renderer';
public const TAG_INVOICE_NUMBER_GENERATOR = 'invoice.number_generator';
@@ -56,6 +57,7 @@ class Kernel extends BaseKernel
$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(PluginInterface::class)->addTag(self::TAG_PLUGIN);
}
public function registerBundles()
@@ -67,6 +69,12 @@ class Kernel extends BaseKernel
}
}
// do not load Kimai plugin in test environment, they may alter the default behaviour and create
// false-negatives in integration/system tests
if ('test' === $this->environment) {
return;
}
$pluginsDir = $this->getProjectDir() . '/var/plugins';
if (!file_exists($pluginsDir)) {
return;
@@ -110,7 +118,6 @@ class Kernel extends BaseKernel
$container->addCompilerPass(new DoctrineCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new TwigContextCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new InvoiceServiceCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new ExportServiceCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
}
protected function configureRoutes(RouteCollectionBuilder $routes)

83
src/Plugin/Plugin.php Normal file
View File

@@ -0,0 +1,83 @@
<?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\Plugin;
class Plugin
{
/**
* @var string
*/
private $name;
/**
* @var string
*/
private $path;
/**
* @var PluginMetadata
*/
private $metadata;
/**
* @return PluginMetadata
*/
public function getMetadata(): ?PluginMetadata
{
return $this->metadata;
}
/**
* @param PluginMetadata $metadata
* @return Plugin
*/
public function setMetadata(PluginMetadata $metadata)
{
$this->metadata = $metadata;
return $this;
}
/**
* @return string
*/
public function getPath(): ?string
{
return $this->path;
}
/**
* @param string $path
* @return Plugin
*/
public function setPath(string $path)
{
$this->path = $path;
return $this;
}
/**
* @return string
*/
public function getName(): ?string
{
return $this->name;
}
/**
* @param string $name
* @return Plugin
*/
public function setName(string $name)
{
$this->name = $name;
return $this;
}
}

View File

@@ -0,0 +1,23 @@
<?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\Plugin;
interface PluginInterface
{
/**
* @return string
*/
public function getName();
/**
* @return string
*/
public function getPath();
}

View File

@@ -0,0 +1,111 @@
<?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\Plugin;
use App\Constants;
class PluginManager
{
/**
* @var Plugin[]
*/
private $plugins = [];
/**
* @param PluginInterface[] $plugins
* @throws \Exception
*/
public function __construct(iterable $plugins)
{
foreach ($plugins as $plugin) {
$this->addPlugin($plugin);
}
}
/**
* @param PluginInterface $plugin
* @throws \Exception
*/
public function addPlugin(PluginInterface $plugin)
{
if (isset($this->plugins[$plugin->getName()])) {
return;
}
$this->plugins[$plugin->getName()] = $this->createPlugin($plugin);
}
/**
* @return Plugin[]
*/
public function getPlugins()
{
return $this->plugins;
}
/**
* @param string $name
* @return Plugin|null
*/
public function getPlugin(string $name): ?Plugin
{
if (!isset($this->plugins[$name])) {
return null;
}
return $this->plugins[$name];
}
/**
* @param PluginInterface $bundle
* @return Plugin
*/
protected function createPlugin(PluginInterface $bundle)
{
$plugin = new Plugin();
$plugin
->setName($bundle->getName())
->setPath($bundle->getPath())
->setMetadata(new PluginMetadata())
;
return $plugin;
}
/**
* Call this method and pass a plugin, to set its metdata.
* This is not pre-filled by default, as it would mean to parse several composer.json on each request.
*
* @param Plugin $plugin
*/
public function loadMetadata(Plugin $plugin)
{
$composer = $plugin->getPath() . '/composer.json';
if (!file_exists($composer) || !is_readable($composer)) {
return;
}
$json = json_decode(file_get_contents($composer), true);
$reqVersion = $json['extra']['kimai']['require'] ?? 'unknown';
$version = $json['extra']['kimai']['version'] ?? 'unknown';
$description = $json['description'] ?? '';
$homepage = $json['homepage'] ?? Constants::HOMEPAGE . '/store/';
$plugin
->getMetadata()
->setHomepage($homepage)
->setKimaiVersion($reqVersion)
->setVersion($version)
->setDescription($description)
;
}
}

View File

@@ -0,0 +1,106 @@
<?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\Plugin;
class PluginMetadata
{
/**
* @var string
*/
private $version;
/**
* @var string
*/
private $kimaiVersion;
/**
* @var string
*/
private $homepage;
/**
* @var string
*/
private $description;
/**
* @return string
*/
public function getDescription(): ?string
{
return $this->description;
}
/**
* @param string $description
* @return PluginMetadata
*/
public function setDescription(string $description)
{
$this->description = $description;
return $this;
}
/**
* @return string
*/
public function getVersion(): ?string
{
return $this->version;
}
/**
* @param string $version
* @return PluginMetadata
*/
public function setVersion(string $version)
{
$this->version = $version;
return $this;
}
/**
* @return string
*/
public function getKimaiVersion(): ?string
{
return $this->kimaiVersion;
}
/**
* @param string $kimaiVersion
* @return PluginMetadata
*/
public function setKimaiVersion(string $kimaiVersion)
{
$this->kimaiVersion = $kimaiVersion;
return $this;
}
/**
* @return string
*/
public function getHomepage(): ?string
{
return $this->homepage;
}
/**
* @param string $homepage
* @return PluginMetadata
*/
public function setHomepage(string $homepage)
{
$this->homepage = $homepage;
return $this;
}
}

View File

@@ -101,6 +101,8 @@ class Extensions extends AbstractExtension
'on' => 'fas fa-toggle-on',
'off' => 'fas fa-toggle-off',
'audit' => 'fas fa-history',
'home' => 'fas fa-home',
'shop' => 'fas fa-shopping-cart',
];
/**

View File

@@ -77,7 +77,7 @@ class TimesheetVoter extends AbstractVoter
switch ($attribute) {
case self::START:
if (!$this->canStart($subject, $user, $token)) {
if (!$this->canStart($subject)) {
return false;
}
$permission .= $attribute;
@@ -113,11 +113,9 @@ class TimesheetVoter extends AbstractVoter
/**
* @param Timesheet $timesheet
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canStart(Timesheet $timesheet, User $user, TokenInterface $token)
protected function canStart(Timesheet $timesheet)
{
// possible improvements for the future:
// we could check the amount of active entries (maybe slow)

View File

@@ -74,7 +74,7 @@ class UserVoter extends AbstractVoter
switch ($attribute) {
// special case for the UserController
case self::DELETE:
if (!$this->canDelete($subject, $user, $token)) {
if (!$this->canDelete($subject, $user)) {
return false;
}
@@ -114,7 +114,7 @@ class UserVoter extends AbstractVoter
* @param User $user
* @return bool
*/
protected function canDelete(User $profile, User $user, TokenInterface $token)
protected function canDelete(User $profile, User $user)
{
return $profile->getId() !== $user->getId();
}

View File

@@ -338,9 +338,6 @@
"setasign/fpdi": {
"version": "1.6.2"
},
"squizlabs/php_codesniffer": {
"version": "3.2.2"
},
"swiftmailer/swiftmailer": {
"version": "v6.0.2"
},
@@ -482,6 +479,9 @@
"symfony/polyfill-intl-icu": {
"version": "v1.6.0"
},
"symfony/polyfill-intl-idn": {
"version": "v1.11.0"
},
"symfony/polyfill-mbstring": {
"version": "v1.6.0"
},

View File

@@ -1,38 +1,57 @@
{% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block page_title %}{{ 'about.title'|trans({}, 'about') }}{% endblock %}
{% block page_subtitle %}{{ 'about.subtitle'|trans({}, 'about') }}{% endblock %}
{% block main %}
{{ widgets.callout('info', 'Thank you for using Kimai ' ~ constant('App\\Constants::VERSION') ~ ' ' ~ constant('App\\Constants::STATUS') ~ ' - ' ~ constant('App\\Constants::NAME'), 'Kimai ' ~ constant('App\\Constants::VERSION'), 'fas fa-thumbs-up') }}
<div class="row">
<div class="col-md-12">
<div class="nav-tabs-custom">
<ul class="nav nav-tabs">
<li class="active"><a href="#kimai" data-toggle="tab" aria-expanded="true">Kimai</a></li>
<li><a href="#system" data-toggle="tab" aria-expanded="true">System</a></li>
<li><a href="#license" data-toggle="tab" aria-expanded="true">License</a></li>
<ul class="nav nav-tabs" role="tablist">
<li role="presentation"class ="active"><a href="#systeminfo" aria-controls="systeminfo" role="tab" data-toggle="tab">{{ 'tab.system'|trans({}, 'about') }}</a></li>
<li role="presentation"><a href="#license" aria-controls="license" role="tab" data-toggle="tab">{{ 'tab.license'|trans({}, 'about') }}</a></li>
</ul>
<div class="tab-content">
<div class="tab-pane active" id="kimai">
<dl>
<dt>Version</dt><dd>{{ constant('App\\Constants::VERSION') }}</dd>
<dt>Status</dt><dd>{{ constant('App\\Constants::STATUS') }}</dd>
<dt>Name</dt><dd>{{ constant('App\\Constants::NAME') }}</dd>
</dl>
</div>
<div class="tab-pane" id="system">
<div role="tabpanel" class="tab-pane active" id="systeminfo">
<div class="box-group" id="accordion">
{% if is_granted('system_actions') %}
<div class="panel box">
<div class="box-header with-border">
<h4 class="box-title">
<a data-toggle="collapse" data-parent="#accordion" href="#collapseTwo" class="" aria-expanded="true">
<a data-toggle="collapse" data-parent="#accordion" href="#collapseSystemAction" aria-expanded="true">
{{ 'label.actions'|trans }}
</a>
</h4>
</div>
<div id="collapseSystemAction" class="panel-collapse collapse in" aria-expanded="true">
<div class="box-body">
<p>
<a href="{{ path('system_flush_cache') }}" class="btn btn-primary" onclick="$(this).html('Clear cache <i class=\'fas fa-spinner fa-spin\'></i>')">Clear cache</a>
</p>
{% if content_action is defined and content_action is not empty %}
<pre>
{{ content_action }}
</pre>
{% endif %}
</div>
</div>
</div>
{% endif %}
<div class="panel box">
<div class="box-header with-border">
<h4 class="box-title">
<a data-toggle="collapse" role="button" data-parent="#accordion" href="#collapseEnvironment" aria-expanded="false" aria-controls="collapseEnvironment">
Environment
</a>
</h4>
</div>
<div id="collapseTwo" class="panel-collapse collapse in" aria-expanded="true" style="">
<div id="collapseEnvironment" class="panel-collapse collapse" aria-expanded="false" role="tabpanel">
<div class="box-body">
<dl>
@@ -48,12 +67,12 @@
<div class="panel box">
<div class="box-header with-border">
<h4 class="box-title">
<a data-toggle="collapse" data-parent="#accordion" href="#collapseThree" class="collapsed" aria-expanded="false">
<a data-toggle="collapse" data-parent="#accordion" href="#collapsePhp" class="collapsed" aria-expanded="false">
PHP
</a>
</h4>
</div>
<div id="collapseThree" class="panel-collapse collapse" aria-expanded="false" style="height: 0px;">
<div id="collapsePhp" class="panel-collapse collapse" aria-expanded="false">
<div class="box-body">
<dl>
@@ -77,12 +96,12 @@
<div class="panel box">
<div class="box-header with-border">
<h4 class="box-title">
<a data-toggle="collapse" data-parent="#accordion" href="#collapseFour" class="collapsed" aria-expanded="false">
<a data-toggle="collapse" data-parent="#accordion" href="#collapseSystem" class="collapsed" aria-expanded="false">
Server
</a>
</h4>
</div>
<div id="collapseFour" class="panel-collapse collapse" aria-expanded="false" style="height: 0px;">
<div id="collapseSystem" class="panel-collapse collapse" aria-expanded="false">
<div class="box-body">
<dl>
@@ -96,9 +115,8 @@
</div>
</div>
</div>
</div>
<div class="tab-pane" id="license">
<div role="tabpanel" class="tab-pane" id="license">
<pre>{{ license }}</pre>
<p>
<a href="https://choosealicense.com/licenses/mit/" target="_blank">choosealicense.com</a>,

View File

@@ -10,6 +10,8 @@
{% endblock %}
{% block avanzu_page_content_before %}
{% set event = trigger(constant('App\\Event\\ThemeEvent::CONTENT_BEFORE')) %}
{{ event.content|raw }}
<div class="toolbar-pad no-print">
{% block main_before %}{% endblock %}
</div>
@@ -17,10 +19,16 @@
{% block avanzu_page_content_after %}
{% block main_after %}{% endblock %}
{% set event = trigger(constant('App\\Event\\ThemeEvent::CONTENT_AFTER')) %}
{{ event.content|raw }}
{% endblock %}
{% block avanzu_page_content %}
{% set event = trigger(constant('App\\Event\\ThemeEvent::CONTENT_START')) %}
{{ event.content|raw }}
{% block main %}{% endblock %}
{% set event = trigger(constant('App\\Event\\ThemeEvent::CONTENT_END')) %}
{{ event.content|raw }}
{% endblock %}
{% block avanzu_document_title %}

View File

@@ -222,3 +222,25 @@
{{ widgets.page_actions(event.payload.actions) }}
{% endif %}
{% endmacro %}
{% macro plugins(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {'shop': {'url': constant('App\\Constants::HOMEPAGE') ~ '/store/', 'target': '_blank'}} %}
{% set event = trigger('actions.plugins', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}
{% macro plugin(plugin, view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {'home': {'url': plugin.metadata.homepage, 'target': '_blank'}} %}
{% set event = trigger('actions.plugin', {'actions': actions, 'view': view}) %}
{% if view == 'index' %}
{{ widgets.button_group(event.payload.actions) }}
{% else %}
{{ widgets.page_actions(event.payload.actions) }}
{% endif %}
{% endmacro %}

View File

@@ -153,6 +153,7 @@
{% set modal = null %}
{% set toggle = null %}
{% set url = null %}
{% set target = null %}
{% set class = "btn btn-default btn-" ~ icon ~ " " %}
{% if not values is iterable %}
@@ -173,6 +174,7 @@
{% set onclick = values.onclick ?? null %}
{% set modal = values.modal ?? null %}
{% set toggle = values.toggle ?? null %}
{% set target = values.target ?? null %}
{% set id = values.id ?? null %}
{% set class = class ~ ( values.class | default("")) %}
{% endif %}
@@ -190,6 +192,9 @@
{%- if onclick is not empty -%}
onclick="{{ onclick }}"
{%- endif -%}
{%- if target is not empty -%}
target="{{ target }}"
{%- endif -%}
>{{ macro.icon(icon) }}</a>
{% endfor -%}
</div>

View File

@@ -0,0 +1,58 @@
{% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/actions.html.twig" as actions %}
{% block page_title %}{{ 'plugins.title'|trans({}, 'plugins') }}{% endblock %}
{% block page_subtitle %}{{ 'plugins.subtitle'|trans({}, 'plugins') }}{% endblock %}
{% block page_actions %}{{ actions.plugins('index') }}{% endblock %}
{% block main %}
{% if plugins|length == 0 %}
{{ widgets.callout('warning', 'plugin.none_installed'|trans({}, 'plugins')) }}
{% else %}
<div class="box data_table" id="datatable_plugins">
<div class="box-body no-padding">
<div class="dataTables_wrapper form-inline dt-bootstrap">
<div class="row">
<div class="col-sm-12">
<table class="table table-striped table-hover dataTable" role="grid">
<thead>
<tr>
<th>{{ 'label.name'|trans }}</th>
<th>{{ 'label.version'|trans({}, 'plugins') }}</th>
<th class="hidden-xs">{{ 'label.description'|trans }}</th>
<th class="hidden-xs hidden-sm">{{ 'label.required_version'|trans({}, 'plugins') }}</th>
<th>{{ 'label.actions'|trans }}</th>
</tr>
</thead>
<tbody>
{% for plugin in plugins %}
<tr>
<td>{{ plugin.name }}</td>
<td>{{ widgets.label(plugin.metadata.version, 'primary') }}</td>
<td class="hidden-xs">{{ plugin.metadata.description }}</td>
<td class="hidden-xs hidden-sm">
{% if plugin.metadata.kimaiVersion > constant('App\\Constants::VERSION') %}
{{ widgets.label(plugin.metadata.kimaiVersion, 'danger') }}
{% elseif plugin.metadata.kimaiVersion < constant('App\\Constants::VERSION') %}
{{ widgets.label(plugin.metadata.kimaiVersion, 'warning') }}
{% else %}
{{ widgets.label(plugin.metadata.kimaiVersion, 'success') }}
{% endif %}
</td>
<td>
{{ actions.plugin(plugin, 'index') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -31,10 +31,10 @@ class ConfigurationControllerTest extends APIControllerBaseTest
$this->assertIsArray($result);
$this->assertNotEmpty($result);
$this->assertEquals(7, count($result));
$this->assertStructure($result, false);
$this->assertStructure($result);
}
protected function assertStructure(array $result, $full = true)
protected function assertStructure(array $result)
{
$expectedKeys = ['date', 'date_time', 'duration', 'form_date', 'form_date_time', 'is24hours', 'time'];
$actual = array_keys($result);

View File

@@ -29,9 +29,9 @@ class AboutControllerTest extends ControllerBaseTest
$this->assertAccessIsGranted($client, '/admin/about');
$result = $client->getCrawler()->filter('div.nav-tabs-custom ul.nav.nav-tabs li');
$this->assertEquals(3, count($result));
$this->assertEquals(2, count($result));
$result = $client->getCrawler()->filter('div.nav-tabs-custom div.tab-content div.tab-pane');
$this->assertEquals(3, count($result));
$this->assertEquals(2, count($result));
}
}

View File

@@ -31,6 +31,7 @@ class CalendarControllerTest extends ControllerBaseTest
$crawler = $client->getCrawler();
$calendar = $crawler->filter('div#calendar');
$this->assertEquals(1, $calendar->count());
}
public function testCalendarEntriesAction()

View File

@@ -202,6 +202,23 @@ abstract class ControllerBaseTest extends WebTestCase
$this->assertEquals($count, $node->count());
}
/**
* @param Client $client
* @param array $buttons
*/
protected function assertPageActions(Client $client, array $buttons)
{
$node = $client->getCrawler()->filter('section.content-header div.breadcrumb div.box-tools div.btn-group a.btn');
$this->assertEquals(count($buttons), $node->count());
foreach ($node->getIterator() as $element) {
$expectedClass = str_replace('btn btn-default btn-', '', $element->getAttribute('class'));
$this->assertArrayHasKey($expectedClass, $buttons);
$expectedUrl = $buttons[$expectedClass];
$this->assertEquals($expectedUrl, $element->getAttribute('href'));
}
}
/**
* @param string $role the USER role to use for the request
* @param string $url the URL of the page displaying the initial form to submit

View File

@@ -40,7 +40,6 @@ class ExportControllerTest extends ControllerBaseTest
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$begin = new \DateTime('first day of this month');
$end = new \DateTime('last day of this month');
$fixture = new TimesheetFixtures();
$fixture
->setUser($this->getUserByRole($em, User::ROLE_USER))

View File

@@ -0,0 +1,48 @@
<?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\Tests\Controller;
use App\Entity\User;
use App\Plugin\PluginManager;
use App\Tests\Plugin\Fixtures\TestPlugin;
/**
* @coversDefaultClass \App\Controller\PluginController
* @group integration
*/
class PluginControllerTest extends ControllerBaseTest
{
public function testIsSecure()
{
$this->assertUrlIsSecured('/admin/plugins/');
$this->assertUrlIsSecuredForRole(User::ROLE_ADMIN, '/admin/plugins/');
}
public function testIndexAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/plugins/');
$this->assertCalloutWidgetWithMessage($client, 'You have no plugin installed yet');
$this->assertPageActions($client, ['shop' => 'https://www.kimai.org/store/']);
}
public function testIndexActionWithInstalledPlugins()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
/** @var PluginManager $manager */
$manager = self::$container->get(PluginManager::class);
$manager->addPlugin(new TestPlugin());
$this->assertAccessIsGranted($client, '/admin/plugins/');
$this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_plugins', 1);
}
}

View File

@@ -11,7 +11,7 @@ namespace App\Tests\Export\Renderer;
use App\Export\Renderer\HtmlRenderer;
use Symfony\Component\HttpFoundation\Request;
use Twig\Loader\FilesystemLoader;
use Twig\Environment;
/**
* @covers \App\Export\Renderer\HtmlRenderer
@@ -22,7 +22,7 @@ class HtmlRendererTest extends AbstractRendererTest
public function testConfiguration()
{
$sut = new HtmlRenderer(
$this->getMockBuilder(\Twig_Environment::class)->disableOriginalConstructor()->getMock()
$this->getMockBuilder(Environment::class)->disableOriginalConstructor()->getMock()
);
$this->assertEquals('html', $sut->getId());
@@ -33,16 +33,13 @@ class HtmlRendererTest extends AbstractRendererTest
public function testRender()
{
$kernel = self::bootKernel();
/** @var \Twig_Environment $twig */
/** @var Environment $twig */
$twig = $kernel->getContainer()->get('twig');
$stack = $kernel->getContainer()->get('request_stack');
$request = new Request();
$request->setLocale('en');
$stack->push($request);
/** @var FilesystemLoader $loader */
$loader = $twig->getLoader();
$sut = new HtmlRenderer($twig);
$response = $this->render($sut);

View File

@@ -19,7 +19,7 @@ use App\Utils\MPdfConverter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Twig\Loader\FilesystemLoader;
use Twig\Environment;
/**
* @covers \App\Export\Renderer\PDFRenderer
@@ -45,7 +45,7 @@ class PdfRendererTest extends AbstractRendererTest
public function testConfiguration()
{
$sut = new PDFRenderer(
$this->getMockBuilder(\Twig_Environment::class)->disableOriginalConstructor()->getMock(),
$this->getMockBuilder(Environment::class)->disableOriginalConstructor()->getMock(),
$this->getDateTimeFactory(),
$this->getMockBuilder(HtmlToPdfConverter::class)->getMock()
);
@@ -58,7 +58,7 @@ class PdfRendererTest extends AbstractRendererTest
public function testRender()
{
$kernel = self::bootKernel();
/** @var \Twig_Environment $twig */
/** @var Environment $twig */
$twig = $kernel->getContainer()->get('twig');
$stack = $kernel->getContainer()->get('request_stack');
$cacheDir = $kernel->getContainer()->getParameter('kernel.cache_dir');
@@ -67,9 +67,6 @@ class PdfRendererTest extends AbstractRendererTest
$request->setLocale('en');
$stack->push($request);
/** @var FilesystemLoader $loader */
$loader = $twig->getLoader();
$sut = new PDFRenderer($twig, $this->getDateTimeFactory(), $converter);
$response = $this->render($sut);

View File

@@ -12,6 +12,7 @@ namespace App\Tests\Export;
use App\Export\Renderer\HtmlRenderer;
use App\Export\ServiceExport;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
/**
* @covers \App\Export\ServiceExport
@@ -20,22 +21,22 @@ class ServiceExportTest extends TestCase
{
public function testEmptyObject()
{
$sut = new ServiceExport();
$sut = new ServiceExport([]);
$this->assertEmpty($sut->getRenderer());
}
public function testUnknownRendererReturnsNull()
{
$sut = new ServiceExport();
$sut = new ServiceExport([]);
$this->assertNull($sut->getRendererById('default'));
}
public function testAdd()
{
$sut = new ServiceExport();
$sut = new ServiceExport([]);
$sut->addRenderer(new HtmlRenderer(
$this->getMockBuilder(\Twig_Environment::class)->disableOriginalConstructor()->getMock()
$this->getMockBuilder(Environment::class)->disableOriginalConstructor()->getMock()
));
$this->assertEquals(1, count($sut->getRenderer()));

View File

@@ -11,6 +11,7 @@ namespace App\Tests\Invoice\Renderer;
use App\Invoice\Renderer\TwigRenderer;
use Symfony\Component\HttpFoundation\Request;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
/**
@@ -21,7 +22,7 @@ class TwigRendererTest extends AbstractRendererTest
public function testSupports()
{
$loader = new FilesystemLoader();
$env = new \Twig_Environment($loader);
$env = new Environment($loader);
$sut = new TwigRenderer($env);
$this->assertTrue($sut->supports($this->getInvoiceDocument('default.html.twig')));
@@ -37,7 +38,7 @@ class TwigRendererTest extends AbstractRendererTest
public function testRender()
{
$kernel = self::bootKernel();
/** @var \Twig_Environment $twig */
/** @var Environment $twig */
$twig = $kernel->getContainer()->get('twig');
$stack = $kernel->getContainer()->get('request_stack');
$request = new Request();

View File

@@ -16,6 +16,7 @@ use App\Invoice\Renderer\TwigRenderer;
use App\Invoice\ServiceInvoice;
use App\Repository\InvoiceDocumentRepository;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
/**
* @covers \App\Invoice\ServiceInvoice
@@ -65,7 +66,7 @@ class ServiceInvoiceTest extends TestCase
$sut->addNumberGenerator(new DateNumberGenerator());
$sut->addRenderer(
new TwigRenderer(
$this->getMockBuilder(\Twig_Environment::class)->disableOriginalConstructor()->getMock()
$this->getMockBuilder(Environment::class)->disableOriginalConstructor()->getMock()
)
);

View File

@@ -0,0 +1,31 @@
<?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\Tests\Plugin\Fixtures;
use App\Plugin\PluginInterface;
class TestPlugin implements PluginInterface
{
/**
* @return string
*/
public function getName()
{
return 'TestPlugin';
}
/**
* @return string
*/
public function getPath()
{
return __DIR__;
}
}

View File

@@ -0,0 +1,29 @@
{
"name": "kimai/test-plugin-composer",
"description": "Just a test fixture for the PluginManager",
"homepage": "https://github.com/kevinpapst/kimai2",
"type": "kimai-plugin",
"require": {
"kimai/kimai2-composer": "*",
"kevinpapst/kimai2": "*"
},
"keywords": [
"kimai",
"kimai-plugin"
],
"license": "proprietary",
"authors": [
{
"name": "Kevin Papst",
"homepage": "https://www.kevinpapst.de"
}
],
"extra": {
"kimai": {
"require": "0.9",
"version": "1.0",
"name": "TestPlugin from composer.json",
"license": []
}
}
}

View File

@@ -0,0 +1,78 @@
<?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\Tests\Plugin;
use App\Plugin\Plugin;
use App\Plugin\PluginInterface;
use App\Plugin\PluginManager;
use App\Plugin\PluginMetadata;
use App\Tests\Plugin\Fixtures\TestPlugin;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Plugin\PluginManager
*/
class PluginManagerTest extends TestCase
{
public function testEmptyObject()
{
$sut = new PluginManager([]);
$this->assertEmpty($sut->getPlugins());
$this->assertNull($sut->getPlugin('foo'));
}
public function testUnknownRendererReturnsNull()
{
$sut = new PluginManager([]);
$this->assertNull($sut->getPlugin('foo'));
}
public function testAdd()
{
$sut = new PluginManager([]);
$plugin = $this->getMockBuilder(PluginInterface::class)
->setMethods(['getName', 'getPath'])
->getMock();
$plugin->method('getName')->willReturn('foo');
$plugin->method('getPath')->willReturn('bar');
$sut->addPlugin(new TestPlugin());
$sut->addPlugin($plugin);
$sut->addPlugin(new TestPlugin());
// make sure a plugin with the same name is not added twice, the first one wins!
$this->assertEquals(2, count($sut->getPlugins()));
$foo = $sut->getPlugin('foo');
$this->assertInstanceOf(Plugin::class, $foo);
$this->assertEquals('foo', $foo->getName());
$this->assertEquals('bar', $foo->getPath());
$test = $sut->getPlugin('TestPlugin');
$this->assertInstanceOf(Plugin::class, $test);
$this->assertEquals('TestPlugin', $test->getName());
$this->assertEquals(new PluginMetadata(), $test->getMetadata());
}
public function testLoadMetadata()
{
$sut = new PluginManager([new TestPlugin()]);
$plugin = $sut->getPlugin('TestPlugin');
$sut->loadMetadata($plugin);
$meta = $plugin->getMetadata();
$this->assertEquals('0.9', $meta->getKimaiVersion());
$this->assertEquals('1.0', $meta->getVersion());
$this->assertEquals('Just a test fixture for the PluginManager', $meta->getDescription());
$this->assertEquals('https://github.com/kevinpapst/kimai2', $meta->getHomepage());
}
}

View File

@@ -0,0 +1,42 @@
<?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\Tests\Plugin;
use App\Plugin\PluginMetadata;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Plugin\PluginMetadata
*/
class PluginMetadataTest extends TestCase
{
public function testEmptyObject()
{
$sut = new PluginMetadata();
$this->assertNull($sut->getDescription());
$this->assertNull($sut->getHomepage());
$this->assertNull($sut->getVersion());
$this->assertNull($sut->getKimaiVersion());
}
public function testGetterAndSetter()
{
$sut = new PluginMetadata();
$this->assertInstanceOf(PluginMetadata::class, $sut->setVersion('13.7'));
$this->assertInstanceOf(PluginMetadata::class, $sut->setHomepage('http://www.example.com'));
$this->assertInstanceOf(PluginMetadata::class, $sut->setDescription('foo bar'));
$this->assertInstanceOf(PluginMetadata::class, $sut->setKimaiVersion('1.0'));
$this->assertEquals('13.7', $sut->getVersion());
$this->assertEquals('http://www.example.com', $sut->getHomepage());
$this->assertEquals('foo bar', $sut->getDescription());
$this->assertEquals('1.0', $sut->getKimaiVersion());
}
}

View File

@@ -0,0 +1,48 @@
<?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\Tests\Plugin;
use App\Plugin\Plugin;
use App\Plugin\PluginMetadata;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Plugin\Plugin
*/
class PluginTest extends TestCase
{
public function testEmptyObject()
{
$plugin = new Plugin();
$this->assertNull($plugin->getName());
$this->assertNull($plugin->getPath());
$this->assertNull($plugin->getMetadata());
}
public function testGetterAndSetter()
{
$metadata = new PluginMetadata();
$metadata
->setDescription('foo')
->setHomepage('http://www.example.com')
->setVersion('13.7')
->setKimaiVersion('1.1')
;
$plugin = new Plugin();
$this->assertInstanceOf(Plugin::class, $plugin->setName('foo'));
$this->assertInstanceOf(Plugin::class, $plugin->setPath('bar'));
$this->assertInstanceOf(Plugin::class, $plugin->setMetadata($metadata));
$this->assertEquals('foo', $plugin->getName());
$this->assertEquals('bar', $plugin->getPath());
$this->assertSame($metadata, $plugin->getMetadata());
}
}

View File

@@ -19,6 +19,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Intl\Util\IntlTestHelper;
use Twig\TwigFilter;
use Twig\TwigFunction;
/**
* @covers \App\Twig\Extensions
@@ -68,7 +69,7 @@ class ExtensionsTest extends TestCase
$this->assertCount(count($functions), $twigFunctions);
$i = 0;
foreach ($twigFunctions as $filter) {
$this->assertInstanceOf(\Twig_SimpleFunction::class, $filter);
$this->assertInstanceOf(TwigFunction::class, $filter);
$this->assertEquals($functions[$i++], $filter->getName());
}
}

View File

@@ -10,6 +10,14 @@
<source>about.subtitle</source>
<target>System Informationen</target>
</trans-unit>
<trans-unit id="tab.system">
<source>tab.system</source>
<target>System Info</target>
</trans-unit>
<trans-unit id="tab.license">
<source>tab.license</source>
<target>Kimai Lizenz</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -10,6 +10,14 @@
<source>about.subtitle</source>
<target>System information</target>
</trans-unit>
<trans-unit id="tab.system">
<source>tab.system</source>
<target>System Info</target>
</trans-unit>
<trans-unit id="tab.license">
<source>tab.license</source>
<target>Kimai License</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -85,6 +85,10 @@
<source>menu.admin</source>
<target>Administration</target>
</trans-unit>
<trans-unit id="menu.system">
<source>menu.system</source>
<target>System</target>
</trans-unit>
<trans-unit id="menu.logout">
<source>menu.logout</source>
<target>Abmelden</target>
@@ -121,6 +125,10 @@
<source>menu.admin_user</source>
<target>Benutzer</target>
</trans-unit>
<trans-unit id="menu.plugin">
<source>menu.plugin</source>
<target>Erweiterungen</target>
</trans-unit>
<trans-unit id="menu.system_configuration">
<source>menu.system_configuration</source>
<target>Settings</target>

View File

@@ -85,6 +85,10 @@
<source>menu.admin</source>
<target>Administration</target>
</trans-unit>
<trans-unit id="menu.system">
<source>menu.system</source>
<target>System</target>
</trans-unit>
<trans-unit id="menu.logout">
<source>menu.logout</source>
<target>Logout</target>
@@ -121,6 +125,10 @@
<source>menu.admin_user</source>
<target>User</target>
</trans-unit>
<trans-unit id="menu.plugin">
<source>menu.plugin</source>
<target>Plugins</target>
</trans-unit>
<trans-unit id="menu.system_configuration">
<source>menu.system_configuration</source>
<target>System configuration</target>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file date="2018-10-29T10:00:00Z" source-language="en" target-language="de" datatype="plaintext" original="plugins.en.xliff">
<body>
<trans-unit id="plugins.title">
<source>plugins.title</source>
<target>Plugins</target>
</trans-unit>
<trans-unit id="plugins.subtitle">
<source>plugins.subtitle</source>
<target>Mit Plugins können Sie den Funktionsumfang von Kimai erweitern</target>
</trans-unit>
<trans-unit id="label.version">
<source>label.version</source>
<target>Version</target>
</trans-unit>
<trans-unit id="label.required_version">
<source>label.required_version</source>
<target>Kompatibel mit</target>
</trans-unit>
<trans-unit id="plugin.none_installed">
<source>plugin.none_installed</source>
<target>Sie haben noch kein Plugin installiert.</target>
</trans-unit>
<trans-unit id="plugin.marketplace">
<source>plugin.marketplace</source>
<target>Kimai Marketplace</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file date="2018-10-29T10:00:00Z" source-language="en" target-language="en" datatype="plaintext" original="none">
<body>
<trans-unit id="plugins.title">
<source>plugins.title</source>
<target>Plugins</target>
</trans-unit>
<trans-unit id="plugins.subtitle">
<source>plugins.subtitle</source>
<target>You can enhance the functionality of Kimai with plugins</target>
</trans-unit>
<trans-unit id="label.version">
<source>label.version</source>
<target>Version</target>
</trans-unit>
<trans-unit id="label.required_version">
<source>label.required_version</source>
<target>Compatible with</target>
</trans-unit>
<trans-unit id="plugin.none_installed">
<source>plugin.none_installed</source>
<target>You have no plugin installed yet.</target>
</trans-unit>
<trans-unit id="plugin.marketplace">
<source>plugin.marketplace</source>
<target>Kimai marketplace</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -23,15 +23,13 @@ Encore
// generate only two files: app.js and app.css
.addEntry('app', './assets/app.js')
// enable sass/scss parser
.enableSassLoader()
// show OS notifications when builds finish/fail
.enableBuildNotifications()
// load jquery as Kimai and AdminLTE rely on it
.autoProvidejQuery()
// enable sass/scss parser
// see https://symfony.com/doc/current/frontend/encore/bootstrap.html
.enableSassLoader(function(sassOptions) {}, {
resolveUrlLoader: false