Developer improvements, PhpCs, PhpUnit #42 (#43)

* improved dev fixtures with more diversity, more data, better testcases, user avatars #42
* added customer stats to dashboard, fixed column length for 3 widgets #42
* fixed empty alias - display empty message for new user #42
* unified-ui for "new" toolbar icon #42
* use kimai2_ as database prefix #42
* added customer stats to dashboard, fixed column length for 3 widgets #42
* fix long project/customer names in "currently active" navbar flyout" #42
* added services config for dev environment #42
* added command to run unit tests #42
* added command to run integration tests #42
* added command to run code sniffer #42
* added phpcs and phpunit to composer #42
* created tests directory #42
* fixed code sniffer warnings #42
* added function to switch result type from Pagerfanta to QueryBuilder #42
* dramatically reduced database calls by using custom joined query #42
* added command to install kimai dependencies #42
* added dev:reset command #42
This commit is contained in:
Kevin Papst
2018-01-07 15:11:03 +01:00
committed by GitHub
parent fa8e976e76
commit f8b390cbe2
63 changed files with 2424 additions and 302 deletions

View File

@@ -464,7 +464,7 @@
</trans-unit>
<trans-unit id="admin_user.subtitle">
<source>admin_user.subtitle</source>
<target>Alle registrierten Nutzer</target>
<target>Alle registrierten Benutzer der Zeitverwaltung</target>
</trans-unit>
<trans-unit id="label.alias">
<source>label.alias</source>
@@ -546,9 +546,9 @@
<source>stats.userActiveEver</source>
<target>Aktive Benutzer jemals</target>
</trans-unit>
<trans-unit id="stats.userActiveNow">
<source>stats.userActiveNow</source>
<target>Momentan aktiv</target>
<trans-unit id="stats.activeRecordings">
<source>stats.activeRecordings</source>
<target>Momentan aktive Zeitmessungen</target>
</trans-unit>
<trans-unit id="stats.activitiesTotal">
<source>stats.activitiesTotal</source>
@@ -558,6 +558,10 @@
<source>stats.projectsTotal</source>
<target>Anzahl Projekte</target>
</trans-unit>
<trans-unit id="stats.customerTotal">
<source>stats.customerTotal</source>
<target>Anzahl Kunden</target>
</trans-unit>
<!--
Month names

View File

@@ -6,21 +6,20 @@
{% block main %}
{# blue / yellow / purple / green / black #}
{# bar-chart / line-chart / calendar / clock-o #}
{% for settings in dashboard_widgets %}
{% if settings.header %}
{{ widgets.page_header(settings.header) }}
{% endif %}
{% set columnWidth = 12 / (settings.widgets|length) %}
{% set width = settings.widgets|length %}
{% set rawWidth = 12 / width %}
{% set columnWidth = rawWidth|round(0, 'floor') %}
<div class="row">
{% for widgetTemplate in settings.widgets %}
<div class="col-md-{{ columnWidth }} col-sm-{{ columnWidth * 2 }} col-xs-{{ columnWidth * 4 }}">
{% set widgetString = '{% import "macros/widgets.html.twig" as widgets %}' ~ widgetTemplate %}
{{ include(template_from_string(widgetString)) }}
</div>
<div class="col-md-{{ columnWidth }} col-sm-{{ columnWidth * 2 }} col-xs-{{ columnWidth * 4 }}">
{% set widgetString = '{% import "macros/widgets.html.twig" as widgets %}' ~ widgetTemplate %}
{{ include(template_from_string(widgetString)) }}
</div>
{% endfor %}
</div>
{% endfor %}

View File

@@ -60,13 +60,20 @@
</div>
{% endmacro %}
{% macro info_box_counter(title, amount, icon, color) %}
{% macro info_box_counter(title, amount, icon, color, url) %}
<div class="info-box">
<span class="info-box-icon bg-{{ color|default(kimai_context.box_color) }}"><i class="fa fa-{{ icon|default('flag-o') }}"></i></span>
<div class="info-box-content">
{# this is a ugly hack, make me look nicely (dashboard widget with link) #}
{% if url %}
<a href="{{ url }}" class="small-box-footer">
{% endif %}
<span class="info-box-text">{{ title|trans }}</span>
<span class="info-box-number">{{ amount }}</span>
{% if url %}
</a>
{% endif %}
</div>
</div>
{% endmacro %}

View File

@@ -4,12 +4,13 @@
{% block page_subtitle %}{{ 'profile.subtitle'|trans }}{% endblock %}
{% block main %}
{% import _self as widgets %}
{% import _self as macro %}
{% import "macros/widgets.html.twig" as widgets %}
<div class="row">
<div class="col-md-3">
{{ widgets.profile_box(user, stats) }}
{{ widgets.profile_infos(user, stats) }}
{{ macro.profile_box(user, stats) }}
{{ macro.profile_infos(user, stats) }}
</div>
<div class="col-md-9">
@@ -28,6 +29,10 @@
</ul>
<div class="tab-content">
<div class="tab-pane {% if tab == "charts" %}active{% endif %}" id="charts">
{% if years is empty %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{% endif %}
<script type="text/javascript">
var barChartOptions = {
scaleBeginAtZero: true,
@@ -148,7 +153,7 @@
<div class="box box-primary">
<div class="box-body box-profile">
{{ macro.avatar(user.avatar, user.username, 'profile-user-img img-responsive img-circle') }}
<h3 class="profile-username text-center">{{ user.alias }}</h3>
<h3 class="profile-username text-center">{{ user.alias|default(user.username) }}</h3>
<p class="text-muted text-center">{{ user.title }}</p>
@@ -188,7 +193,7 @@
{{ macro.avatar(user.avatar, user.username) }}
</div>
<!-- /.widget-user-image -->
<h3 class="widget-user-username">{{ user.alias }} ({{ user.username }})</h3>
<h3 class="widget-user-username">{{ user.alias|default(user.username) }}</h3>
<h5 class="widget-user-desc">{{ user.title }}</h5>
</div>
<div class="box-footer no-padding">
@@ -207,7 +212,7 @@
<div class="box box-widget widget-user">
<div class="widget-user-header bg-{{ color|default(kimai_context.box_color) }}">
<h3 class="widget-user-username">{{ user.alias }} ({{ user.username }})</h3>
<h3 class="widget-user-username">{{ user.alias|default(user.username) }}</h3>
<h5 class="widget-user-desc">{{ user.title }}</h5>
</div>
<div class="widget-user-image">

View File

@@ -1,5 +1,6 @@
imports:
- { resource: config.yml }
- { resource: services_dev.yml }
framework:
router:

View File

@@ -11,7 +11,7 @@ parameters:
# database_url: 'mysql://root:pass@127.0.0.1:3306/timesheet'
# You can prefix all your tables to pevent collision with other software
database_prefix: kimai_
database_prefix: kimai2_
# You can create the database and load the sample data from the command line:
#

View File

@@ -6,20 +6,19 @@ security:
providers:
# in this example, users are stored via Doctrine in the database
# To see the users at src/AppBundle/DataFixtures/ORM/LoadFixtures.php
# To load users from somewhere else: http://symfony.com/doc/current/cookbook/security/custom_provider.html
# demo users can be found at src/AppBundle/DataFixtures/ORM/LoadFixtures.php
database_users:
entity: { class: AppBundle:User, property: username }
# http://symfony.com/doc/current/book/security.html#firewalls-authentication
firewalls:
secured_area:
# this firewall applies to all URLs
pattern: ^/
# does what it says
logout_on_user_change: true
# this firewall applies to all URLs
pattern: ^/
# but the firewall does not require login on every page
# denying access is done in access_control or in your controllers
anonymous: ~

View File

@@ -1,12 +1,12 @@
services:
# ================================================================================
# TWIG
# ================================================================================
markdown:
class: AppBundle\Utils\Markdown
# ================================================================================
# TWIG
# ================================================================================
app.twig.app_extension:
public: false
class: AppBundle\Twig\Extensions
@@ -26,9 +26,9 @@ services:
tags:
- { name: twig.extension }
# ================================================================================
# SECURITY
# ================================================================================
# ================================================================================
# SECURITY
# ================================================================================
# security voter to check user-profile access
app.voter.user:
@@ -58,9 +58,9 @@ services:
tags:
- { name: security.voter }
# ================================================================================
# FORMS
# ================================================================================
# ================================================================================
# FORMS
# ================================================================================
# form to edit user roles
app.admin.user_profile_roles:
@@ -76,9 +76,9 @@ services:
tags:
- { name: kernel.event_listener, event: theme.sidebar_setup_menu, method: onSetupNavbar }
# ================================================================================
# DATABASE
# ================================================================================
# ================================================================================
# DATABASE
# ================================================================================
# service that prefixes every database table
app.tableprefix_subscriber:
@@ -94,18 +94,18 @@ services:
# app.post_repository:
# class: Doctrine\ORM\EntityRepository
# factory: ['@doctrine.orm.entity_manager', getRepository]
# arguments: [AppBundle\Entity\Post]
# arguments: [AppBundle\Entity\User]
#
# // traditional code inside a controller
# $entityManager = $this->getDoctrine()->getManager();
# $posts = $entityManager->getRepository('AppBundle:Post')->findAll();
# $posts = $entityManager->getRepository('AppBundle:User')->findAll();
#
# // same code using repository services
# $posts = $this->get('app.post_repository')->findAll();
# $posts = $this->get('app.user_repository')->findAll();
# ================================================================================
# THEME
# ================================================================================
# ================================================================================
# THEME
# ================================================================================
avanzu_admin_theme.navbar_user_listener:
class: AppBundle\EventListener\NavbarShowUserListener
@@ -114,30 +114,30 @@ services:
- { name: kernel.event_listener, event: theme.navbar_user, method: onShowUser }
- { name: kernel.event_listener, event: theme.sidebar_user, method: onShowUser }
# avanzu_admin_theme.navbar_task_listener:
# class: "%avanzu_admin_theme.navbar_task_listener.class%"
# tags:
# - { name: kernel.event_listener, event: theme.tasks, method: onListTasks }
#
# avanzu_admin_theme.navbar_notify_listener:
# class: "%avanzu_admin_theme.navbar_notify_listener.class%"
# tags:
# - { name: kernel.event_listener, event: theme.notifications, method: onListNotifications }
#
# avanzu_admin_theme.navbar_msg_listener:
# class: "%avanzu_admin_theme.navbar_msg_listener.class%"
# tags:
# - { name: kernel.event_listener, event: theme.messages, method: onListMessages }
#
# avanzu_admin_theme.setup_menu_listener:
# class: "%avanzu_admin_theme.setup_menu_listener.class%"
# tags:
# - { name: kernel.event_listener, event: theme.sidebar_setup_menu, method: onSetupMenu }
# - { name: kernel.event_listener, event: theme.breadcrumb, method: onSetupMenu }
# avanzu_admin_theme.navbar_task_listener:
# class: "%avanzu_admin_theme.navbar_task_listener.class%"
# tags:
# - { name: kernel.event_listener, event: theme.tasks, method: onListTasks }
#
# avanzu_admin_theme.navbar_notify_listener:
# class: "%avanzu_admin_theme.navbar_notify_listener.class%"
# tags:
# - { name: kernel.event_listener, event: theme.notifications, method: onListNotifications }
#
# avanzu_admin_theme.navbar_msg_listener:
# class: "%avanzu_admin_theme.navbar_msg_listener.class%"
# tags:
# - { name: kernel.event_listener, event: theme.messages, method: onListMessages }
#
# avanzu_admin_theme.setup_menu_listener:
# class: "%avanzu_admin_theme.setup_menu_listener.class%"
# tags:
# - { name: kernel.event_listener, event: theme.sidebar_setup_menu, method: onSetupMenu }
# - { name: kernel.event_listener, event: theme.breadcrumb, method: onSetupMenu }
# ================================================================================
# APPLICATION CORE
# ================================================================================
# ================================================================================
# APPLICATION CORE
# ================================================================================
# a route listener, that injects the locale through a URL directory
app.redirect_to_preferred_locale_listener:
@@ -153,9 +153,10 @@ services:
- { name: kernel.event_listener, event: app.main_menu_configure, method: onMainMenuConfigure }
- { name: kernel.event_listener, event: app.admin_menu_configure, method: onAdminMenuConfigure }
# ================================================================================
# CONSOLE COMMANDS
# ================================================================================
# ================================================================================
# CONSOLE COMMANDS
# ================================================================================
app.command.create_user:
class: AppBundle\Command\CreateUserCommand
arguments: ["@security.password_encoder", "@doctrine", "@validator"]

View File

@@ -0,0 +1,32 @@
imports:
- { resource: services.yml }
services:
# ================================================================================
# CONSOLE COMMANDS
# ================================================================================
app.command.run_phpcs:
class: AppBundle\Command\RunCodeSnifferCommand
arguments: ["%kernel.root_dir%"]
tags:
- { name: console.command, command: kimai:dev:phpcs }
app.command.run_unit_tests:
class: AppBundle\Command\RunUnitTestsCommand
arguments: ["%kernel.root_dir%"]
tags:
- { name: console.command, command: kimai:dev:test-unit }
app.command.run_integration_tests:
class: AppBundle\Command\RunIntegrationTestsCommand
arguments: ["%kernel.root_dir%"]
tags:
- { name: console.command, command: kimai:dev:test-integration }
app.command.dev_reset:
class: AppBundle\Command\DevResetCommand
arguments: ["%kernel.root_dir%"]
tags:
- { name: console.command, command: kimai:dev:reset }

View File

@@ -44,7 +44,9 @@
},
"require-dev": {
"sensio/generator-bundle": "~3.0",
"symfony/phpunit-bridge": "^3.0"
"symfony/phpunit-bridge": "^3.0",
"squizlabs/php_codesniffer": "3.*",
"phpunit/phpunit": "^6"
},
"scripts": {
"post-install-cmd": [

1494
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -32,4 +32,4 @@ class AppBundle extends Bundle
$container->addCompilerPass(new CompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
}
}
}

View File

@@ -48,8 +48,11 @@ class CreateUserCommand extends Command
* @param RegistryInterface $registry
* @param ValidatorInterface $validator
*/
public function __construct(UserPasswordEncoderInterface $encoder, RegistryInterface $registry, ValidatorInterface $validator)
{
public function __construct(
UserPasswordEncoderInterface $encoder,
RegistryInterface $registry,
ValidatorInterface $validator
) {
$this->encoder = $encoder;
$this->doctrine = $registry;
$this->validator = $validator;
@@ -70,7 +73,7 @@ class CreateUserCommand extends Command
->addArgument('email', InputArgument::REQUIRED, 'Users email address (must be unique)')
->addArgument('password', InputArgument::REQUIRED, 'Users password')
->addArgument('language', InputArgument::OPTIONAL, 'Users language', User::DEFAULT_LANGUAGE)
->addArgument('role', InputArgument::OPTIONAL, 'Users role (can be a comma separated list)', User::DEFAULT_ROLE)
->addArgument('role', InputArgument::OPTIONAL, 'Users role (comma separated list)', User::DEFAULT_ROLE)
;
}
@@ -104,10 +107,11 @@ class CreateUserCommand extends Command
$errors = $this->validator->validate($user);
if ($errors->count() > 0) {
/** @var \Symfony\Component\Validator\ConstraintViolation $error */
foreach($errors as $error) {
foreach ($errors as $error) {
$value = $error->getInvalidValue();
$io->error(
$error->getPropertyPath()
. " (" . (is_array($error->getInvalidValue()) ? implode(',', $error->getInvalidValue()) : $error->getInvalidValue()).")"
. " (" . (is_array($value) ? implode(',', $value) : $value) .")"
. "\n "
. $error->getMessage()
);

View File

@@ -0,0 +1,131 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Command used to execute all the basic application bootstrapping AFTER "composer install" was executed.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class DevResetCommand extends Command
{
/**
* @inheritdoc
*/
protected function configure()
{
$this
->setName('kimai:dev:reset')
->setDescription('Resets the dev environment')
->setHelp(<<<EOT
This command will drop and re-create the database and its schemas, load development fixtures and clear the cache.
Use the <info>-n</info> switch to skip the question.
EOT
)
->addOption('no-cache', null, InputOption::VALUE_NONE, 'Skip cache flushing')
;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @return int|null
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
if ($input->isInteractive()) {
if (!$this->askConfirmation(
$input,
$output,
'<question>Careful, database will be purged. Do you want to continue y/N ?</question>',
false
)) {
return;
}
}
$command = $this->getApplication()->find('doctrine:database:create');
try {
$command->run(new ArrayInput([]), $output);
} catch (\Exception $ex) {
$io->error('Failed to create database: ' . $ex->getMessage());
return 1;
}
$command = $this->getApplication()->find('doctrine:schema:drop');
try {
$command->run(new ArrayInput(['--force' => true]), $output);
} catch (\Exception $ex) {
$io->error('Failed to drop database schema: ' . $ex->getMessage());
return 2;
}
$command = $this->getApplication()->find('doctrine:schema:create');
try {
$command->run(new ArrayInput([]), $output);
} catch (\Exception $ex) {
$io->error('Failed to create database schema: ' . $ex->getMessage());
return 3;
}
$command = $this->getApplication()->find('doctrine:fixtures:load');
try {
$cmdInput = new ArrayInput([]);
$cmdInput->setInteractive(false);
$command->run($cmdInput, $output);
} catch (\Exception $ex) {
$io->error('Failed to import fixtures: ' . $ex->getMessage());
return 4;
}
if ($input->getOption('no-cache')) {
return 0;
}
$command = $this->getApplication()->find('cache:clear');
try {
$command->run(new ArrayInput([]), $output);
} catch (\Exception $ex) {
$io->error('Failed to clear cache: ' . $ex->getMessage());
return 5;
}
return 0;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @param string $question
* @param bool $default
* @return bool
*/
private function askConfirmation(InputInterface $input, OutputInterface $output, $question, $default)
{
$questionHelper = $this->getHelperSet()->get('question');
$question = new ConfirmationQuestion($question, $default);
return $questionHelper->ask($input, $output, $question);
}
}

View File

@@ -0,0 +1,92 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Command used to execute all the basic application bootstrapping AFTER "composer install" was executed.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class InstallCommand extends Command
{
/**
* @inheritdoc
*/
protected function configure()
{
$this
->setName('kimai:install')
->setDescription('Execute all the basic installation tasks')
->setHelp('This command will bootstrap Kimai, copies asset installation by default')
->addOption('symlink', null, InputOption::VALUE_NONE, 'Symlinks the assets instead of copying it')
->addOption('relative', null, InputOption::VALUE_NONE, 'Make relative symlinks')
;
}
/**
* @inheritdoc
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$arguments = [];
if ($input->getOption('relative')) {
$arguments = [
'--relative' => true,
];
} elseif ($input->getOption('symlink')) {
$arguments = [
'--symlink' => true,
];
}
if ($this->installAssets($output, $io, 'assets:install', $arguments)) {
$this->installAssets($output, $io, 'avanzu:admin:initialize', $arguments);
}
}
/**
* @param OutputInterface $output
* @param SymfonyStyle $io
* @param string $cmdName
* @param array $args
* @return bool
*/
protected function installAssets(OutputInterface $output, SymfonyStyle $io, $cmdName, $args = [])
{
$command = $this->getApplication()->find($cmdName);
try {
$returnCode = $command->run(new ArrayInput($args), $output);
} catch (\Exception $ex) {
$io->error('Failed to install assets via "'.$cmdName.'": ' . $ex->getMessage());
return false;
}
if ($returnCode != 0) {
$io->error('Failed to install assets via "'.$cmdName.'"');
return false;
}
return true;
}
}

View File

@@ -0,0 +1,84 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Command used to check the project coding styles.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class RunCodeSnifferCommand extends Command
{
/**
* @var string
*/
protected $rootDir;
/**
* RunCodeSnifferCommand constructor.
* @param $rootDir
*/
public function __construct($rootDir)
{
$this->rootDir = realpath($rootDir . '/../');
parent::__construct();
}
/**
* @inheritdoc
*/
protected function configure()
{
$this
->setName('kimai:dev:phpcs')
->setDescription('Run PHP_CodeSniffer to check for the projects coding style')
;
}
/**
* @inheritdoc
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$this->executeCodeSniffer($io, '/src');
$this->executeCodeSniffer($io, '/tests');
$this->executeCodeSniffer($io, '/app/Resources/views');
}
/**
* @param string $directory
*/
protected function executeCodeSniffer(SymfonyStyle $io, $directory)
{
$directory = $this->rootDir . $directory;
$exitCode = 0;
ob_start();
passthru($this->rootDir . '/bin/phpcs --standard=PSR2 ' . $directory, $exitCode);
$result = ob_get_clean();
$io->write($result);
if ($exitCode > 0) {
$io->error('Found problems while checking sources at: ' . $directory);
} else {
$io->success('All sources look good at: ' . $directory);
}
}
}

View File

@@ -0,0 +1,46 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Command used to run all integration tests.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class RunIntegrationTestsCommand extends RunUnitTestsCommand
{
/**
* @inheritdoc
*/
protected function configure()
{
$this
->setName('kimai:dev:test-integration')
->setDescription('Run all integration tests')
->setHelp('This command will execute all integration tests with the annotation "@group integration".')
;
}
/**
* @param $directory
* @return string
*/
protected function createPhpunitCmdLine($directory)
{
return $this->rootDir . '/bin/phpunit --group integration ' . $directory;
}
}

View File

@@ -0,0 +1,94 @@
<?php
/*
* This file is part of the Kimai package.
*
* (c) Kevin Papst <kevin@kevinpapst.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Command used to run all unit tests.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class RunUnitTestsCommand extends Command
{
/**
* @var string
*/
protected $rootDir;
/**
* RunCodeSnifferCommand constructor.
* @param $rootDir
*/
public function __construct($rootDir)
{
$this->rootDir = realpath($rootDir . '/../');
parent::__construct();
}
/**
* @inheritdoc
*/
protected function configure()
{
$this
->setName('kimai:dev:test-unit')
->setDescription('Run all unit tests')
->setHelp('This command will execute all unit tests. Skips all tests with "@group integration" annotation.')
;
}
/**
* @inheritdoc
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$this->executeTests($io, '/tests/AppBundle');
$this->executeTests($io, '/tests/TimesheetBundle');
}
/**
* @param $directory
* @return string
*/
protected function createPhpunitCmdLine($directory)
{
return $this->rootDir . '/bin/phpunit --exclude-group integration ' . $directory;
}
/**
* @param string $directory
*/
protected function executeTests(SymfonyStyle $io, $directory)
{
$directory = $this->rootDir . $directory;
$exitCode = 0;
ob_start();
passthru($this->createPhpunitCmdLine($directory), $exitCode);
$result = ob_get_clean();
$io->write($result);
if ($exitCode > 0) {
$io->error('Found problems while running tests at: ' . $directory);
} else {
$io->success('All tests performed good at: ' . $directory);
}
}
}

View File

@@ -43,13 +43,13 @@ abstract class AbstractController extends Controller
*
* @param $attributes
* @param null $subject
* @param string $translationKey
* @param string $translation
* @param array $parameter
* @throws AccessDeniedException
*/
protected function denyUnlessGranted($attributes, $subject = null, $translationKey = 'access.denied', $parameter = [])
protected function denyUnlessGranted($attributes, $subject = null, $translation = 'access.denied', $parameter = [])
{
$error = $this->getTranslator()->trans($translationKey, $parameter, self::DOMAIN_ERROR);
$error = $this->getTranslator()->trans($translation, $parameter, self::DOMAIN_ERROR);
// TODO try & catch and add to audit log?
$this->denyAccessUnlessGranted($attributes, $subject, $error);
}

View File

@@ -75,9 +75,7 @@ class UserController extends AbstractController
$this->flashSuccess('action.updated_successfully');
return $this->redirectToRoute(
'user_profile_edit', ['username' => $user->getUsername()]
);
return $this->redirectToRoute('user_profile_edit', ['username' => $user->getUsername()]);
}
return $this->render(
@@ -87,7 +85,6 @@ class UserController extends AbstractController
'form' => $editForm->createView()
]
);
}
/**

View File

@@ -12,18 +12,14 @@
namespace AppBundle\Controller;
use AppBundle\Entity\User;
use AppBundle\Model\UserStatistic;
use TimesheetBundle\Entity\Activity;
use TimesheetBundle\Entity\Customer;
use TimesheetBundle\Entity\Project;
use TimesheetBundle\Entity\Timesheet;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use TimesheetBundle\Model\ActivityStatistic;
use TimesheetBundle\Model\ProjectStatistic;
use TimesheetBundle\Model\TimesheetGlobalStatistic;
use TimesheetBundle\Model\TimesheetStatistic;
/**
* Dashboard controller for the admin area.
@@ -49,25 +45,29 @@ class DashboardController extends Controller
$activityStats = $this->getDoctrine()->getRepository(Activity::class)->getGlobalStatistics();
$projectStats = $this->getDoctrine()->getRepository(Project::class)->getGlobalStatistics();
$customerStats = $this->getDoctrine()->getRepository(Customer::class)->getGlobalStatistics();
$userStats = $this->getDoctrine()->getRepository(User::class)->getGlobalStatistics();
return $this->render('dashboard/index.html.twig', [
'dashboard_widgets' => $this->getWidgets($timesheetUserStats, $timesheetGlobalStats, $activityStats, $projectStats, $userStats),
'dashboard_widgets' => $this->getWidgets(),
'timesheetGlobal' => $timesheetGlobalStats,
'timesheetUser' => $timesheetUserStats,
'activity' => $activityStats,
'project' => $projectStats,
'customer' => $customerStats,
'user' => $userStats,
]);
}
protected function getWidgets(
TimesheetStatistic $timesheetUserStats,
TimesheetGlobalStatistic $timesheetGlobalStats,
ActivityStatistic $activityStats,
ProjectStatistic $projectStats,
UserStatistic $userStats
) {
/**
* colors: blue / yellow / purple / green / black
* icons: bar-chart / line-chart / calendar / clock-o
*
* @return array
*/
protected function getWidgets()
{
// @codingStandardsIgnoreStart
$widgets = [
/*
[
@@ -104,6 +104,7 @@ class DashboardController extends Controller
//"{{ widgets.info_box_counter('stats.amountThisMonth', timesheetGlobal.amountThisMonth|money, 'money', 'green') }}",
"{{ widgets.info_box_counter('stats.durationTotal', timesheetGlobal.durationTotal|duration(true), 'hourglass-o', 'yellow') }}",
//"{{ widgets.info_box_counter('stats.amountTotal', timesheetGlobal.amountTotal|money, 'money', 'red') }}",
"{{ widgets.info_box_counter('stats.activeRecordings', timesheetGlobal.activeCurrently, 'hourglass-o', 'red', path('admin_timesheet', {'state': 1})) }}",
],
];
@@ -111,10 +112,9 @@ class DashboardController extends Controller
'id' => 'user.stats',
'header' => '',
'widgets' => [
"{{ widgets.info_box_counter('stats.userTotal', user.totalAmount, 'users', 'red') }}",
"{{ widgets.info_box_counter('stats.userActiveThisMoth', timesheetGlobal.activeThisMonth, 'users', 'yellow') }}",
"{{ widgets.info_box_counter('stats.userActiveEver', timesheetGlobal.activeTotal, 'users', 'blue') }}",
"{{ widgets.info_box_counter('stats.userActiveNow', timesheetGlobal.activeCurrently, 'users', 'green') }}",
"{{ widgets.info_box_counter('stats.userTotal', user.totalAmount, 'user', 'red') }}",
"{{ widgets.info_box_counter('stats.userActiveThisMoth', timesheetGlobal.activeThisMonth, 'user', 'yellow') }}",
"{{ widgets.info_box_counter('stats.userActiveEver', timesheetGlobal.activeTotal, 'user', 'blue') }}",
],
];
@@ -126,12 +126,13 @@ class DashboardController extends Controller
'id' => 'admin.stats',
'header' => 'dashboard.admin',
'widgets' => [
"{{ widgets.info_box_more('stats.activitiesTotal', activity.totalAmount, '', path('admin_activity'), 'tasks', 'purple') }}",
"{{ widgets.info_box_more('stats.userTotal', user.totalAmount, ' ', path('admin_user'), 'user') }}",
"{{ widgets.info_box_more('stats.customerTotal', customer.totalAmount, '', path('admin_customer'), 'users', 'blue') }}",
"{{ widgets.info_box_more('stats.projectsTotal', project.totalAmount, '', path('admin_project'), 'book', 'yellow') }}",
"{{ widgets.info_box_more('stats.userTotal', user.totalAmount, ' ', path('admin_user'), 'users') }}",
"{{ widgets.info_box_more('stats.userActiveNow', timesheetGlobal.activeCurrently, '', path('admin_timesheet', {'state': 1}), 'hourglass-o', 'red') }}", // FIXME ???
"{{ widgets.info_box_more('stats.activitiesTotal', activity.totalAmount, '', path('admin_activity'), 'tasks', 'purple') }}",
],
];
// @codingStandardsIgnoreEnd
return $widgets;
}

View File

@@ -60,9 +60,7 @@ class ProfileController extends AbstractController
$this->flashSuccess('action.updated_successfully');
return $this->redirectToRoute(
'user_profile', ['username' => $profile->getUsername()]
);
return $this->redirectToRoute('user_profile', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, $editForm, null, null, 'profile');
@@ -89,9 +87,7 @@ class ProfileController extends AbstractController
$this->flashSuccess('action.updated_successfully');
return $this->redirectToRoute(
'user_profile', ['username' => $profile->getUsername()]
);
return $this->redirectToRoute('user_profile', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, null, $pwdForm, null, 'password');
@@ -114,9 +110,7 @@ class ProfileController extends AbstractController
$this->flashSuccess('action.updated_successfully');
return $this->redirectToRoute(
'user_profile', ['username' => $profile->getUsername()]
);
return $this->redirectToRoute('user_profile', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, null, null, $rolesForm, 'roles');
@@ -145,8 +139,13 @@ class ProfileController extends AbstractController
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Doctrine\ORM\NonUniqueResultException
*/
protected function getProfileView(User $user, Form $editForm = null, Form $pwdForm = null, Form $rolesForm = null, $tab = 'charts')
{
protected function getProfileView(
User $user,
Form $editForm = null,
Form $pwdForm = null,
Form $rolesForm = null,
$tab = 'charts'
) {
/* @var $timesheetRepo TimesheetRepository */
$timesheetRepo = $this->getDoctrine()->getRepository(Timesheet::class);
$userStats = $timesheetRepo->getUserStatistics($user);

View File

@@ -24,6 +24,9 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
*/
class LoadFixtures implements FixtureInterface, ContainerAwareInterface
{
const DEFAULT_PASSWORD = 'kitten';
/** @var ContainerInterface */
private $container;
@@ -45,18 +48,18 @@ class LoadFixtures implements FixtureInterface, ContainerAwareInterface
$claraCustomer->setUsername('clara_customer');
$claraCustomer->setEmail('clara_customer@example.com');
$claraCustomer->setRoles(['ROLE_CUSTOMER']);
$encodedPassword = $passwordEncoder->encodePassword($claraCustomer, 'kitten');
$claraCustomer->setPassword($encodedPassword);
$claraCustomer->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y');
$claraCustomer->setPassword($passwordEncoder->encodePassword($claraCustomer, self::DEFAULT_PASSWORD));
$manager->persist($claraCustomer);
$johnUser = new User();
$johnUser->setAlias('John Doe');
$johnUser->setTitle('Lead Developer');
$johnUser->setTitle('Developer');
$johnUser->setUsername('john_user');
$johnUser->setEmail('john_user@example.com');
$johnUser->setRoles(['ROLE_USER']);
$encodedPassword = $passwordEncoder->encodePassword($johnUser, 'kitten');
$johnUser->setPassword($encodedPassword);
$johnUser->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y');
$johnUser->setPassword($passwordEncoder->encodePassword($claraCustomer, self::DEFAULT_PASSWORD));
$manager->persist($johnUser);
$tonyTeamlead = new User();
@@ -65,8 +68,8 @@ class LoadFixtures implements FixtureInterface, ContainerAwareInterface
$tonyTeamlead->setUsername('tony_teamlead');
$tonyTeamlead->setEmail('tony_teamlead@example.com');
$tonyTeamlead->setRoles(['ROLE_TEAMLEAD']);
$encodedPassword = $passwordEncoder->encodePassword($tonyTeamlead, 'kitten');
$tonyTeamlead->setPassword($encodedPassword);
$tonyTeamlead->setAvatar('https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg');
$tonyTeamlead->setPassword($passwordEncoder->encodePassword($claraCustomer, self::DEFAULT_PASSWORD));
$manager->persist($tonyTeamlead);
$annaAdmin = new User();
@@ -75,8 +78,8 @@ class LoadFixtures implements FixtureInterface, ContainerAwareInterface
$annaAdmin->setUsername('anna_admin');
$annaAdmin->setEmail('anna_admin@example.com');
$annaAdmin->setRoles(['ROLE_ADMIN']);
$encodedPassword = $passwordEncoder->encodePassword($annaAdmin, 'kitten');
$annaAdmin->setPassword($encodedPassword);
// no avatar to test default image!
$annaAdmin->setPassword($passwordEncoder->encodePassword($claraCustomer, self::DEFAULT_PASSWORD));
$manager->persist($annaAdmin);
$susanSuper = new User();
@@ -85,8 +88,8 @@ class LoadFixtures implements FixtureInterface, ContainerAwareInterface
$susanSuper->setUsername('susan_super');
$susanSuper->setEmail('susan_super@example.com');
$susanSuper->setRoles(['ROLE_SUPER_ADMIN']);
$encodedPassword = $passwordEncoder->encodePassword($susanSuper, 'kitten');
$susanSuper->setPassword($encodedPassword);
$susanSuper->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=wavatar&f=y');
$susanSuper->setPassword($passwordEncoder->encodePassword($claraCustomer, self::DEFAULT_PASSWORD));
$manager->persist($susanSuper);
$manager->flush();

View File

@@ -31,4 +31,4 @@ class AppExtension extends Extension
$classLoader = new ClassLoader('DoctrineExtensions', $extensionsDir);
$classLoader->register();
}
}
}

View File

@@ -17,7 +17,7 @@ use Symfony\Component\DependencyInjection\Exception\ParameterNotFoundException;
use Symfony\Component\Yaml\Yaml;
/**
* Class Configuration
* Class CompilerPass, dynamically loads additional doctrine functions for the configured database engine.
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
@@ -57,4 +57,4 @@ class CompilerPass implements CompilerPassInterface
}
}
}
}
}

View File

@@ -40,11 +40,12 @@ class TablePrefixSubscriber implements \Doctrine\Common\EventSubscriber
return;
}
$classMetadata->setTableName($this->prefix . $classMetadata->getTableName());
$classMetadata->setPrimaryTable(['name' => $this->prefix . $classMetadata->getTableName()]);
foreach ($classMetadata->getAssociationMappings() as $fieldName => $mapping) {
if ($mapping['type'] == \Doctrine\ORM\Mapping\ClassMetadataInfo::MANY_TO_MANY
// Check if "joinTable" exists, it can be null if this field is the reverse side of a ManyToMany relationship
// 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'] = $this->prefix . $mappedTableName;

View File

@@ -12,7 +12,13 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
* User
*
* @ORM\Entity(repositoryClass="AppBundle\Repository\UserRepository")
* @ORM\Table(name="users", uniqueConstraints={@ORM\UniqueConstraint(name="name", columns={"name"}), @ORM\UniqueConstraint(name="mail", columns={"mail"})})
* @ORM\Table(
* name="users",
* uniqueConstraints={
* @ORM\UniqueConstraint(name="name", columns={"name"}),
* @ORM\UniqueConstraint(name="mail", columns={"mail"})
* }
* )
* @UniqueEntity("username")
* @UniqueEntity("email")
*
@@ -381,4 +387,4 @@ class User implements UserInterface
{
return $this->getAlias() ?: $this->getUsername();
}
}
}

View File

@@ -11,10 +11,6 @@
namespace AppBundle\Event;
use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface;
use Symfony\Component\EventDispatcher\Event;
/**
* The ConfigureMainMenuEvent is used for populating the main navigation.
*

View File

@@ -11,7 +11,6 @@
namespace AppBundle\Event;
use Avanzu\AdminThemeBundle\Model\MenuItemModel;
use Avanzu\AdminThemeBundle\Event\SidebarMenuEvent;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\EventDispatcher\Event;
@@ -41,14 +40,13 @@ abstract class ConfigureMenuEvent extends Event
* ConfigureMenuEvent constructor.
* @param AuthorizationChecker $auth
* @param Request $request
* @param MenuItemModel $menuModel
* @param SidebarMenuEvent $event
*/
public function __construct(
AuthorizationChecker $auth,
Request $request,
SidebarMenuEvent $event
)
{
) {
$this->auth = $auth;
$this->request = $request;
$this->event = $event;

View File

@@ -13,7 +13,6 @@ namespace AppBundle\EventListener;
use AppBundle\Event\ConfigureMainMenuEvent;
use AppBundle\Event\ConfigureAdminMenuEvent;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationChecker;
use Avanzu\AdminThemeBundle\Model\MenuItemModel;
@@ -40,10 +39,7 @@ class MenuBuilder
* @param EventDispatcherInterface $dispatcher
* @param AuthorizationChecker $security
*/
public function __construct(
EventDispatcherInterface $dispatcher,
AuthorizationChecker $security
)
public function __construct(EventDispatcherInterface $dispatcher, AuthorizationChecker $security)
{
$this->eventDispatcher = $dispatcher;
$this->security = $security;
@@ -102,7 +98,6 @@ class MenuBuilder
$event->getRequest()->get('_route'),
$event->getItems()
);
}
/**
@@ -111,16 +106,14 @@ class MenuBuilder
*/
protected function activateByRoute($route, $items)
{
foreach($items as $item) {
if($item->hasChildren()) {
foreach ($items as $item) {
if ($item->hasChildren()) {
$this->activateByRoute($route, $item->getChildren());
}
else {
if($item->getRoute() == $route) {
} else {
if ($item->getRoute() == $route) {
$item->setIsActive(true);
}
}
}
}
}

View File

@@ -15,7 +15,6 @@ use AppBundle\Entity\User;
use Avanzu\AdminThemeBundle\Event\ShowUserEvent;
use Avanzu\AdminThemeBundle\Model\UserModel;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Class NavbarShowUserListener
@@ -62,4 +61,4 @@ class NavbarShowUserListener
$event->setUser($user);
}
}
}

View File

@@ -60,7 +60,9 @@ class RedirectToPreferredLocaleListener
$this->defaultLocale = $defaultLocale ?: $this->locales[0];
if (!in_array($this->defaultLocale, $this->locales)) {
throw new \UnexpectedValueException(sprintf('The default locale ("%s") must be one of "%s".', $this->defaultLocale, $locales));
throw new \UnexpectedValueException(
sprintf('The default locale ("%s") must be one of "%s".', $this->defaultLocale, $locales)
);
}
// Add the default locale at the first position of the array,

View File

@@ -43,6 +43,8 @@ class UserRolesType extends AbstractType
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$roles = [];
foreach ($this->roles as $key => $value) {
$roles[$key] = $key;
foreach ($value as $value2) {

View File

@@ -11,14 +11,12 @@
namespace AppBundle\Repository;
use AppBundle\Entity\User;
use TimesheetBundle\Entity\Activity;
use TimesheetBundle\Entity\Timesheet;
use AppBundle\Repository\Query\BaseQuery;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
use TimesheetBundle\Model\ActivityStatistic;
/**
* Class AbstractRepository
@@ -27,6 +25,19 @@ use TimesheetBundle\Model\ActivityStatistic;
*/
abstract class AbstractRepository extends EntityRepository
{
/**
* @param QueryBuilder $qb
* @param BaseQuery $query
* @return QueryBuilder|Pagerfanta
*/
protected function getBaseQueryResult(QueryBuilder $qb, BaseQuery $query)
{
if ($query->getResultType() == BaseQuery::RESULT_TYPE_PAGER) {
return $this->getPager($qb->getQuery(), $query->getPage(), $query->getPageSize());
}
return $qb;
}
/**
* @param Query $query

View File

@@ -22,6 +22,9 @@ class BaseQuery
const DEFAULT_PAGESIZE = 25;
const DEFAULT_PAGE = 1;
const RESULT_TYPE_PAGER = 'PagerFanta';
const RESULT_TYPE_QUERYBUILDER = 'QueryBuilder';
/**
* @var int
*/
@@ -38,6 +41,10 @@ class BaseQuery
* @var string
*/
protected $order = 'ASC';
/**
* @var string
*/
protected $resultType = self::RESULT_TYPE_PAGER;
/**
* @return int
@@ -116,4 +123,24 @@ class BaseQuery
}
return $this;
}
/**
* @return string
*/
public function getResultType()
{
return $this->resultType;
}
/**
* @param string $resultType
* @return BaseQuery
*/
public function setResultType($resultType)
{
if (in_array($resultType, [self::RESULT_TYPE_PAGER, self::RESULT_TYPE_QUERYBUILDER])) {
$this->resultType = $resultType;
}
return $this;
}
}

View File

@@ -13,8 +13,6 @@ namespace AppBundle\Twig;
use AppBundle\Utils\Markdown;
use Symfony\Component\Intl\Intl;
use DateTime;
use DateInterval;
use TimesheetBundle\Entity\Customer;
use TimesheetBundle\Entity\Timesheet;
@@ -31,19 +29,19 @@ class Extensions extends \Twig_Extension
private $parser;
/**
* @var array
* @var string[]
*/
private $locales;
/**
* Extensions constructor.
* @param Markdown $parser
* @param $locales
* @param string $locales
*/
public function __construct(Markdown $parser, $locales)
{
$this->parser = $parser;
$this->locales = $locales;
$this->locales = explode('|', $locales);
}
/**
@@ -158,11 +156,9 @@ class Extensions extends \Twig_Extension
*/
public function getLocales()
{
$localeCodes = explode('|', $this->locales);
$locales = [];
foreach ($localeCodes as $localeCode) {
$locales[] = ['code' => $localeCode, 'name' => Intl::getLocaleBundle()->getLocaleName($localeCode, $localeCode)];
foreach ($this->locales as $locale) {
$locales[] = ['code' => $locale, 'name' => Intl::getLocaleBundle()->getLocaleName($locale, $locale)];
}
return $locales;

View File

@@ -38,7 +38,7 @@ class RoleValidator extends ConstraintValidator
$roles = [$roles];
}
foreach($roles as $role) {
foreach ($roles as $role) {
if (!is_string($role) || !in_array($role, $this->allowedRoles)) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $this->formatValue($role))

View File

@@ -36,7 +36,10 @@ class UserVoter extends AbstractVoter
*/
protected function supports($attribute, $subject)
{
if (!in_array($attribute, [self::VIEW, self::VIEW_ALL, self::EDIT, self::CREATE, self::ROLES, self::PASSWORD, self::DELETE])) {
if (!in_array(
$attribute,
[self::VIEW, self::VIEW_ALL, self::EDIT, self::CREATE, self::ROLES, self::PASSWORD, self::DELETE]
)) {
return false;
}
@@ -71,7 +74,7 @@ class UserVoter extends AbstractVoter
case self::CREATE:
// create actually passes in the current user as $subject, not the new one
case self::DELETE:
// if we ever allow to delete user for ADMIN we have to check if the user to be deleted is not in a higher level
// if we allow to delete user for ADMIN: make sure the user to be deleted is not in a higher level
case self::ROLES:
return $this->canAdminUsers($token);
}

View File

@@ -44,7 +44,8 @@ class ActivityController extends Controller
public function recentActivitiesAction()
{
$user = $this->getUser();
$activeEntries = $this->getRepository()->getRecentActivities($user, new \DateTime('-30 days')); // TODO make days configurable
// TODO make days configurable
$activeEntries = $this->getRepository()->getRecentActivities($user, new \DateTime('-30 days'));
return $this->render(
'TimesheetBundle:Navbar:recent-activities.html.twig',

View File

@@ -14,14 +14,12 @@ namespace TimesheetBundle\Controller\Admin;
use AppBundle\Controller\AbstractController;
use Pagerfanta\Pagerfanta;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use TimesheetBundle\Entity\Activity;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
use TimesheetBundle\Form\ActivityEditForm;
use TimesheetBundle\Repository\ActivityRepository;
use TimesheetBundle\Repository\Query\ActivityQuery;
/**
@@ -89,9 +87,7 @@ class ActivityController extends AbstractController
$this->flashSuccess('action.updated_successfully');
return $this->redirectToRoute(
'admin_activity', ['id' => $activity->getId()]
);
return $this->redirectToRoute('admin_activity', ['id' => $activity->getId()]);
}
return $this->render(
@@ -105,7 +101,7 @@ class ActivityController extends AbstractController
/**
* @param Activity $activity
* @return \Symfony\Component\Form\Form
* @return \Symfony\Component\Form\FormInterface
*/
private function createEditForm(Activity $activity)
{

View File

@@ -87,9 +87,7 @@ class CustomerController extends AbstractController
$this->flashSuccess('action.updated_successfully');
return $this->redirectToRoute(
'admin_customer', ['id' => $customer->getId()]
);
return $this->redirectToRoute('admin_customer', ['id' => $customer->getId()]);
}
return $this->render(

View File

@@ -88,9 +88,7 @@ class ProjectController extends AbstractController
$this->flashSuccess('action.updated_successfully');
return $this->redirectToRoute(
'admin_project', ['id' => $project->getId()]
);
return $this->redirectToRoute('admin_project', ['id' => $project->getId()]);
}
return $this->render(

View File

@@ -34,10 +34,16 @@ class TimesheetController extends AbstractController
use TimesheetControllerTrait;
/**
* This route shows all users timesheet entries.
*
* @Route("/", defaults={"page": 1}, name="admin_timesheet")
* @Route("/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_timesheet_paginated")
* @Method("GET")
* @Cache(smaxage="10")
*
* @param $page
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function indexAction($page, Request $request)
{
@@ -62,10 +68,9 @@ class TimesheetController extends AbstractController
* @Method({"GET"})
*
* @param Timesheet $entry
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function stopAction(Timesheet $entry, Request $request)
public function stopAction(Timesheet $entry)
{
try {
$this->getRepository()->stopRecording($entry);

View File

@@ -89,7 +89,13 @@ class TimesheetController extends AbstractController
// make sure only ADMIN can stop other users entries
if ($user->getId() !== $entry->getUser()->getId()) {
$this->denyUnlessGranted('ROLE_ADMIN', null, 'timesheet.access.denied', ['%user%' => $user->getId(), '%entry%' => $entry->getId()]);
// TODO move me to a voter
$this->denyUnlessGranted(
'ROLE_ADMIN',
null,
'timesheet.access.denied',
['%user%' => $user->getId(), '%entry%' => $entry->getId()]
);
}
try {
@@ -141,7 +147,13 @@ class TimesheetController extends AbstractController
// make sure only ADMIN can edit other users entries
if ($user->getId() !== $entry->getUser()->getId()) {
$this->denyUnlessGranted('ROLE_ADMIN', null, 'timesheet.access.denied', ['%user%' => $user->getId(), '%entry%' => $entry->getId()]);
// TODO move me to a voter
$this->denyUnlessGranted(
'ROLE_ADMIN',
null,
'timesheet.access.denied',
['%user%' => $user->getId(), '%entry%' => $entry->getId()]
);
}
$editForm = $this->createEditForm($entry, $request->get('page'));
@@ -155,9 +167,7 @@ class TimesheetController extends AbstractController
$this->flashSuccess('action.updated_successfully');
return $this->redirectToRoute(
'timesheet_paginated', ['page' => $request->get('page')]
);
return $this->redirectToRoute('timesheet_paginated', ['page' => $request->get('page')]);
}
return $this->render(

View File

@@ -71,7 +71,7 @@ trait TimesheetControllerTrait
} else {
$customer = null;
}
} else if ($customer !== null) {
} elseif ($customer !== null) {
$repo = $this->getDoctrine()->getRepository(Customer::class);
$customer = $repo->getById($customer);
}

View File

@@ -12,7 +12,6 @@
namespace TimesheetBundle\DataFixtures\ORM;
use AppBundle\Entity\User;
use Symfony\Component\Intl\Intl;
use TimesheetBundle\Entity\Activity;
use TimesheetBundle\Entity\Customer;
use TimesheetBundle\Entity\Project;
@@ -30,8 +29,7 @@ use AppBundle\DataFixtures\ORM\LoadFixtures as AppBundleLoadFixtures;
*/
class LoadFixtures extends AppBundleLoadFixtures
{
const AMOUNT_ACTIVITIES = 10; // maximum activites per project
const AMOUNT_TIMESHEET = 1000; // timesheet entries total
const AMOUNT_TIMESHEET = 5000; // timesheet entries total
const RATE_MIN = 10; // minimum rate for one hour
const RATE_MAX = 80; // maximum rate for one hour
@@ -109,9 +107,11 @@ class LoadFixtures extends AppBundleLoadFixtures
{
$allUser = $this->getAllUsers($manager);
$amountUser = count($allUser);
$allActivity = $this->getAllActivities($manager);
// by using array_pop we make sure that at least one activity has NO entry!
array_pop($allActivity);
for ($i = 0; $i <= self::AMOUNT_TIMESHEET; $i++) {
$entry = $this->createTimesheetEntry(
$allUser[rand(1, $amountUser)],
@@ -123,14 +123,18 @@ class LoadFixtures extends AppBundleLoadFixtures
$manager->persist($entry);
}
// leave one running time entry for each user
for ($i = 1; $i <= $amountUser; $i++) {
$entry = $this->createTimesheetEntry(
$allUser[$i],
$allActivity[array_rand($allActivity)]
);
// by using array_pop we make sure that at least one user has NO running entry!
array_pop($allUser);
$manager->persist($entry);
// create active recodinge for test user
foreach ($allUser as $id => $user) {
for ($i = 0; $i < rand(1, 4); $i++) {
$entry = $this->createTimesheetEntry(
$user,
$allActivity[array_rand($allActivity)]
);
$manager->persist($entry);
}
}
$manager->flush();
@@ -174,17 +178,18 @@ class LoadFixtures extends AppBundleLoadFixtures
$amountTimezone = count($allTimezones);
$allCustomer = $this->getCustomers();
$amountCustomer = count($allCustomer);
shuffle($allCustomer);
$i = 0;
for ($i = 0; $i < $amountCustomer; $i++) {
foreach ($allCustomer as $customerName) {
$entry = new Customer();
$entry
->setCurrency($this->getRandomCurrency())
->setVat(rand(0, 30))
->setName($allCustomer[$i])
->setName($customerName)
->setAddress($this->getRandomLocation())
->setComment($this->getRandomPhrase())
->setVisible($i % 3 != 0)
->setVisible($i++ % 3 != 0)
->setTimezone($allTimezones[rand(1, $amountTimezone)]);
$manager->persist($entry);
@@ -195,19 +200,21 @@ class LoadFixtures extends AppBundleLoadFixtures
private function loadProjects(ObjectManager $manager)
{
$allCustomer = $this->getAllCustomers($manager);
$amountCustomer = count($allCustomer);
for ($i = 0; $i < $amountCustomer * 2; $i++) {
foreach ($allCustomer as $id => $customer) {
$projectForCustomer = rand(0, 7);
for ($i = 0; $i < $projectForCustomer; $i++) {
$entry = new Project();
$entry = new Project();
$entry
->setName($this->getRandomProject())
->setBudget(rand(1000, 100000))
->setComment($this->getRandomPhrase())
->setCustomer($allCustomer[($i % $amountCustomer) + 1])
->setVisible($i % 3 != 0);
$entry
->setName($this->getRandomProject())
->setBudget(rand(500, 100000))
->setComment($this->getRandomPhrase())
->setCustomer($customer)
->setVisible($i % 3 != 0);
$manager->persist($entry);
$manager->persist($entry);
}
}
$manager->flush();
}
@@ -217,7 +224,7 @@ class LoadFixtures extends AppBundleLoadFixtures
$allProject = $this->getAllProjects($manager);
foreach ($allProject as $projectId => $project) {
$activityCount = rand(1, self::AMOUNT_ACTIVITIES);
$activityCount = rand(0, 10);
for ($i = 0; $i < $activityCount; $i++) {
$entry = new Activity();
$entry
@@ -238,12 +245,11 @@ class LoadFixtures extends AppBundleLoadFixtures
private function getActivities()
{
return [
'Design',
'Designing',
'Programming',
'Testing',
'Documentation',
'Pause',
'Internal',
'Research',
'Meeting',
'Hosting',
@@ -256,6 +262,11 @@ class LoadFixtures extends AppBundleLoadFixtures
'Management',
'Setup',
'Planning',
'Skiing',
'Eating',
'Watching TV',
'Talking',
'Cooking'
];
}
@@ -282,8 +293,10 @@ class LoadFixtures extends AppBundleLoadFixtures
'Hosting & Server',
'Customer Relations',
'Infrastructure',
'Princess Cat',
'Software Upgrade',
'Office Management',
'Project X',
];
}
@@ -310,10 +323,20 @@ class LoadFixtures extends AppBundleLoadFixtures
'Amsterdam',
'London',
'San Francisco',
'Tokio',
'Tokyo',
'Berlin',
'Sao Paulo',
'Mexico City',
'Moscow',
'Sankt Petersburg',
'Taiwan',
'Perth',
'Sydney',
'Mumbai',
'Lagos',
'Karachi',
'Shanghai',
'Delhi',
];
}
@@ -342,18 +365,34 @@ class LoadFixtures extends AppBundleLoadFixtures
'Twitter',
'Zend',
'SensioLabs',
'Samsung',
'Huawai',
'Yandex',
'Baidu',
'Alphabet',
'Amazon.com',
'Berkshire Hathaway',
'Facebook',
'ExxonMobil',
'Nestle',
'Johnson & Johnson',
'Alibaba',
'General Electric',
'Procter & Gamble',
'Wal-Mart Stores',
'Novartis',
'Coca-Cola',
'Wikipedia',
'Walt Disney',
'Merck',
'Pfizer',
"L'Oréal Group",
"McDonald's",
'China Petroleum & Chemical',
'GlaxoSmithKline'
];
}
/**
* @return string
*/
private function getRandomCustomer()
{
$all = $this->getCustomers();
return $all[array_rand($all)];
}
/**
* @return string[]
*/

View File

@@ -71,7 +71,11 @@ class Project
/**
* @var Activity[]
*
* @ORM\OneToMany(targetEntity="TimesheetBundle\Entity\Activity", mappedBy="project", cascade={"persist", "merge", "remove"})
* @ORM\OneToMany(
* targetEntity="TimesheetBundle\Entity\Activity",
* mappedBy="project",
* cascade={"persist", "merge", "remove"}
* )
*/
private $activities;

View File

@@ -18,7 +18,13 @@ use Doctrine\ORM\Mapping as ORM;
* Timesheet entity.
*
* @ORM\Entity(repositoryClass="TimesheetBundle\Repository\TimesheetRepository")
* @ORM\Table(name="timesheet", indexes={@ORM\Index(columns={"user"}), @ORM\Index(name="activity", columns={"activity"})})
* @ORM\Table(
* name="timesheet",
* indexes={
* @ORM\Index(columns={"user"}),
* @ORM\Index(name="activity", columns={"activity"})
* }
* )
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/

View File

@@ -77,4 +77,4 @@ class Menu
)
;
}
}
}

View File

@@ -15,6 +15,8 @@ use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use TimesheetBundle\Entity\Activity;
use TimesheetBundle\Repository\ActivityRepository;
use TimesheetBundle\Repository\Query\ActivityQuery;
/**
* Custom form field type to select an activity.
@@ -24,17 +26,6 @@ use TimesheetBundle\Entity\Activity;
class ActivityType extends AbstractType
{
/**
* @param Activity $activity
* @param $key
* @param $index
* @return string
*/
public function groupBy(Activity $activity, $key, $index)
{
return $activity->getProject()->getName();
}
/**
* {@inheritdoc}
*/
@@ -44,7 +35,14 @@ class ActivityType extends AbstractType
'class' => 'TimesheetBundle:Activity',
'choice_label' => 'name',
'choice_value' => 'id',
'group_by' => array($this, 'groupBy'),
'group_by' => function (Activity $activity, $key, $index) {
return $activity->getProject()->getName();
},
'query_builder' => function (ActivityRepository $repo) {
$query = new ActivityQuery();
$query->setResultType(ActivityQuery::RESULT_TYPE_QUERYBUILDER);
return $repo->findByQuery($query);
},
]);
}

View File

@@ -15,6 +15,8 @@ use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use TimesheetBundle\Entity\Project;
use TimesheetBundle\Repository\ProjectRepository;
use TimesheetBundle\Repository\Query\ProjectQuery;
/**
* Custom form field type to select a project.
@@ -33,9 +35,14 @@ class ProjectType extends AbstractType
'class' => 'TimesheetBundle:Project',
'choice_label' => 'name',
'choice_value' => 'id',
'group_by' => function(Project $project, $key, $index) {
'group_by' => function (Project $project, $key, $index) {
return $project->getCustomer()->getName();
},
'query_builder' => function (ProjectRepository $repo) {
$query = new ProjectQuery();
$query->setResultType(ProjectQuery::RESULT_TYPE_QUERYBUILDER);
return $repo->findByQuery($query);
},
]);
}

View File

@@ -69,7 +69,7 @@ class ActivityRepository extends AbstractRepository
$activities = [];
/* @var Timesheet $entry */
foreach($results as $entry) {
foreach ($results as $entry) {
$activities[] = $entry->getActivity();
}
@@ -93,10 +93,9 @@ class ActivityRepository extends AbstractRepository
return $stats;
}
/**
* @param ActivityQuery $query
* @return \Pagerfanta\Pagerfanta
* @return \Doctrine\ORM\QueryBuilder|\Pagerfanta\Pagerfanta
*/
public function findByQuery(ActivityQuery $query)
{
@@ -116,6 +115,6 @@ class ActivityRepository extends AbstractRepository
// TODO check for visibility of customer and project
}
return $this->getPager($qb->getQuery(), $query->getPage(), $query->getPageSize());
return $this->getBaseQueryResult($qb, $query);
}
}

View File

@@ -52,7 +52,7 @@ class CustomerRepository extends AbstractRepository
/**
* @param CustomerQuery $query
* @return \Pagerfanta\Pagerfanta
* @return \Doctrine\ORM\QueryBuilder|\Pagerfanta\Pagerfanta
*/
public function findByQuery(CustomerQuery $query)
{
@@ -68,6 +68,6 @@ class CustomerRepository extends AbstractRepository
$qb->andWhere('c.visible = 0');
}
return $this->getPager($qb->getQuery(), $query->getPage(), $query->getPageSize());
return $this->getBaseQueryResult($qb, $query);
}
}

View File

@@ -52,13 +52,14 @@ class ProjectRepository extends AbstractRepository
/**
* @param ProjectQuery $query
* @return \Pagerfanta\Pagerfanta
* @return \Doctrine\ORM\QueryBuilder|\Pagerfanta\Pagerfanta
*/
public function findByQuery(ProjectQuery $query)
{
$qb = $this->getEntityManager()->createQueryBuilder();
// if we join activities, the maxperpage limit will limit the list due to the raised amount of rows by projects * activities
// if we join activities, the maxperpage limit will limit the list
// due to the raised amount of rows by projects * activities
$qb->select('p', 'c')
->from('TimesheetBundle:Project', 'p')
->join('p.customer', 'c')
@@ -72,6 +73,6 @@ class ProjectRepository extends AbstractRepository
// TODO check for visibility of customer
}
return $this->getPager($qb->getQuery(), $query->getPage(), $query->getPageSize());
return $this->getBaseQueryResult($qb, $query);
}
}

View File

@@ -158,5 +158,4 @@ class TimesheetQuery extends BaseQuery
}
return $this;
}
}

View File

@@ -12,12 +12,10 @@
namespace TimesheetBundle\Repository;
use AppBundle\Entity\User;
use AppBundle\Repository\AbstractRepository;
use TimesheetBundle\Entity\Activity;
use TimesheetBundle\Entity\Timesheet;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query;
use Doctrine\DBAL\Types\Type;
use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
use TimesheetBundle\Model\Statistic\Month;
use TimesheetBundle\Model\Statistic\Year;
@@ -31,7 +29,7 @@ use TimesheetBundle\Repository\Query\TimesheetQuery;
*
* @author Kevin Papst <kevin@kevinpapst.de>
*/
class TimesheetRepository extends EntityRepository
class TimesheetRepository extends AbstractRepository
{
/**
@@ -89,7 +87,7 @@ class TimesheetRepository extends EntityRepository
$end = new DateTime('last day of this month');
$end->setTime(23, 59, 59);
$begin = new DateTime('first day of this month');
$begin->setTime(0,0,0);
$begin->setTime(0, 0, 0);
return $this->queryTimeRange($select, $begin, $end, $user);
}
@@ -168,7 +166,7 @@ class TimesheetRepository extends EntityRepository
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('SUM(t.rate) as totalRate, SUM(t.duration) as totalDuration, MONTH(t.begin) as month, YEAR(t.begin) as year')
$qb->select('SUM(t.rate) as rate, SUM(t.duration) as duration, MONTH(t.begin) as month, YEAR(t.begin) as year')
->from('TimesheetBundle:Timesheet', 't')
->where($qb->expr()->gt('t.begin', '0'))
->andWhere($qb->expr()->isNotNull('t.end'))
@@ -183,7 +181,7 @@ class TimesheetRepository extends EntityRepository
}
$years = [];
foreach($qb->getQuery()->execute() as $statRow) {
foreach ($qb->getQuery()->execute() as $statRow) {
$curYear = $statRow['year'];
if (!isset($years[$curYear])) {
@@ -196,8 +194,8 @@ class TimesheetRepository extends EntityRepository
}
$month = new Month($statRow['month']);
$month->setTotalDuration($statRow['totalDuration'])
->setTotalRate($statRow['totalRate']);
$month->setTotalDuration($statRow['duration'])
->setTotalRate($statRow['rate']);
$years[$curYear]->setMonth($month);
}
@@ -310,21 +308,6 @@ class TimesheetRepository extends EntityRepository
->setParameter('customer', $query->getCustomer());
}
return $this->getPager($qb->getQuery(), $query->getPage(), $query->getPageSize());
}
/**
* @param Query $query
* @param int $page
* @param int $maxPerPage
* @return Pagerfanta
*/
protected function getPager(Query $query, $page = 1, $maxPerPage = 25)
{
$paginator = new Pagerfanta(new DoctrineORMAdapter($query, false));
$paginator->setMaxPerPage($maxPerPage);
$paginator->setCurrentPage($page);
return $paginator;
return $this->getBaseQueryResult($qb, $query);
}
}

View File

@@ -21,7 +21,7 @@
'label.currency': 'hidden-xs',
'label.visible': '',
'label.actions': '',
}, null, {'user-plus': path('admin_customer_create')}) }}
}, null, {'plus-square': path('admin_customer_create')}) }}
{% for entry in entries %}
<tr>

View File

@@ -74,6 +74,7 @@ class ActivityVoter extends AbstractVoter
/**
* @param Activity $activity
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canView(Activity $activity, User $user, TokenInterface $token)
@@ -88,6 +89,7 @@ class ActivityVoter extends AbstractVoter
/**
* @param Activity $activity
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canEdit(Activity $activity, User $user, TokenInterface $token)

View File

@@ -74,6 +74,7 @@ class CustomerVoter extends AbstractVoter
/**
* @param Customer $customer
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canView(Customer $customer, User $user, TokenInterface $token)
@@ -88,6 +89,7 @@ class CustomerVoter extends AbstractVoter
/**
* @param Customer $customer
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canEdit(Customer $customer, User $user, TokenInterface $token)

View File

@@ -74,6 +74,7 @@ class ProjectVoter extends AbstractVoter
/**
* @param Project $project
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canView(Project $project, User $user, TokenInterface $token)
@@ -88,6 +89,7 @@ class ProjectVoter extends AbstractVoter
/**
* @param Project $project
* @param User $user
* @param TokenInterface $token
* @return bool
*/
protected function canEdit(Project $project, User $user, TokenInterface $token)

View File

@@ -9,7 +9,7 @@
* file that was distributed with this source code.
*/
namespace AppBundle\Tests\Controller;
namespace KimaiTest\AppBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
@@ -25,6 +25,7 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
* $ cd your-symfony-project/
* $ phpunit -c app
*
* @group integration
*/
class DefaultControllerTest extends WebTestCase
{

View File

@@ -0,0 +1,72 @@
<?php
/**
* Created by PhpStorm.
* User: kevin
* Date: 07.01.18
* Time: 11:12
*/
namespace KimaiTest\TimesheetBundle\Repository\Query;
use AppBundle\Repository\Query\BaseQuery;
use \PHPUnit\Framework\TestCase;
class TimesheetQueryTest extends TestCase
{
public function testBaseQueryHasOverwrittenFields()
{
$class = new \ReflectionClass(new BaseQuery());
$this->assertTrue($class->hasProperty('order'));
$this->assertTrue($class->hasProperty('orderBy'));
}
public function testGetUser()
{
$this->markTestIncomplete(__METHOD__);
}
public function testSetUser()
{
$this->markTestIncomplete(__METHOD__);
}
public function testGetActivity()
{
$this->markTestIncomplete(__METHOD__);
}
public function testSetActivity()
{
$this->markTestIncomplete(__METHOD__);
}
public function testGetProject()
{
$this->markTestIncomplete(__METHOD__);
}
public function testSetProject()
{
$this->markTestIncomplete(__METHOD__);
}
public function testGetCustomer()
{
$this->markTestIncomplete(__METHOD__);
}
public function testSetCustomer()
{
$this->markTestIncomplete(__METHOD__);
}
public function testGetState()
{
$this->markTestIncomplete(__METHOD__);
}
public function testSetState()
{
$this->markTestIncomplete(__METHOD__);
}
}

View File

@@ -9,6 +9,12 @@ li.messages-menu ul.menu li:hover .pull-left i {
color: #dd4b39;
}
.navbar-nav>.messages-menu>.dropdown-menu>li .menu>li>a>p {
overflow: hidden;
text-overflow: ellipsis;
}
/*
.ticktac:hover i.running{
color: #4ff131;