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

View File

@@ -6,15 +6,14 @@
{% block main %} {% block main %}
{# blue / yellow / purple / green / black #}
{# bar-chart / line-chart / calendar / clock-o #}
{% for settings in dashboard_widgets %} {% for settings in dashboard_widgets %}
{% if settings.header %} {% if settings.header %}
{{ widgets.page_header(settings.header) }} {{ widgets.page_header(settings.header) }}
{% endif %} {% 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"> <div class="row">
{% for widgetTemplate in settings.widgets %} {% for widgetTemplate in settings.widgets %}
<div class="col-md-{{ columnWidth }} col-sm-{{ columnWidth * 2 }} col-xs-{{ columnWidth * 4 }}"> <div class="col-md-{{ columnWidth }} col-sm-{{ columnWidth * 2 }} col-xs-{{ columnWidth * 4 }}">

View File

@@ -60,13 +60,20 @@
</div> </div>
{% endmacro %} {% endmacro %}
{% macro info_box_counter(title, amount, icon, color) %} {% macro info_box_counter(title, amount, icon, color, url) %}
<div class="info-box"> <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> <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"> <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-text">{{ title|trans }}</span>
<span class="info-box-number">{{ amount }}</span> <span class="info-box-number">{{ amount }}</span>
{% if url %}
</a>
{% endif %}
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}

View File

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

View File

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

View File

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

View File

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

View File

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

1494
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -48,8 +48,11 @@ class CreateUserCommand extends Command
* @param RegistryInterface $registry * @param RegistryInterface $registry
* @param ValidatorInterface $validator * @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->encoder = $encoder;
$this->doctrine = $registry; $this->doctrine = $registry;
$this->validator = $validator; $this->validator = $validator;
@@ -70,7 +73,7 @@ class CreateUserCommand extends Command
->addArgument('email', InputArgument::REQUIRED, 'Users email address (must be unique)') ->addArgument('email', InputArgument::REQUIRED, 'Users email address (must be unique)')
->addArgument('password', InputArgument::REQUIRED, 'Users password') ->addArgument('password', InputArgument::REQUIRED, 'Users password')
->addArgument('language', InputArgument::OPTIONAL, 'Users language', User::DEFAULT_LANGUAGE) ->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); $errors = $this->validator->validate($user);
if ($errors->count() > 0) { if ($errors->count() > 0) {
/** @var \Symfony\Component\Validator\ConstraintViolation $error */ /** @var \Symfony\Component\Validator\ConstraintViolation $error */
foreach($errors as $error) { foreach ($errors as $error) {
$value = $error->getInvalidValue();
$io->error( $io->error(
$error->getPropertyPath() $error->getPropertyPath()
. " (" . (is_array($error->getInvalidValue()) ? implode(',', $error->getInvalidValue()) : $error->getInvalidValue()).")" . " (" . (is_array($value) ? implode(',', $value) : $value) .")"
. "\n " . "\n "
. $error->getMessage() . $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 $attributes
* @param null $subject * @param null $subject
* @param string $translationKey * @param string $translation
* @param array $parameter * @param array $parameter
* @throws AccessDeniedException * @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? // TODO try & catch and add to audit log?
$this->denyAccessUnlessGranted($attributes, $subject, $error); $this->denyAccessUnlessGranted($attributes, $subject, $error);
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ use Symfony\Component\DependencyInjection\Exception\ParameterNotFoundException;
use Symfony\Component\Yaml\Yaml; 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> * @author Kevin Papst <kevin@kevinpapst.de>
*/ */

View File

@@ -40,11 +40,12 @@ class TablePrefixSubscriber implements \Doctrine\Common\EventSubscriber
return; return;
} }
$classMetadata->setTableName($this->prefix . $classMetadata->getTableName()); $classMetadata->setPrimaryTable(['name' => $this->prefix . $classMetadata->getTableName()]);
foreach ($classMetadata->getAssociationMappings() as $fieldName => $mapping) { foreach ($classMetadata->getAssociationMappings() as $fieldName => $mapping) {
if ($mapping['type'] == \Doctrine\ORM\Mapping\ClassMetadataInfo::MANY_TO_MANY 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']) ) { && array_key_exists('name', $classMetadata->associationMappings[$fieldName]['joinTable']) ) {
$mappedTableName = $classMetadata->associationMappings[$fieldName]['joinTable']['name']; $mappedTableName = $classMetadata->associationMappings[$fieldName]['joinTable']['name'];
$classMetadata->associationMappings[$fieldName]['joinTable']['name'] = $this->prefix . $mappedTableName; $classMetadata->associationMappings[$fieldName]['joinTable']['name'] = $this->prefix . $mappedTableName;

View File

@@ -12,7 +12,13 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
* User * User
* *
* @ORM\Entity(repositoryClass="AppBundle\Repository\UserRepository") * @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("username")
* @UniqueEntity("email") * @UniqueEntity("email")
* *

View File

@@ -11,10 +11,6 @@
namespace AppBundle\Event; 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. * The ConfigureMainMenuEvent is used for populating the main navigation.
* *

View File

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

View File

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

View File

@@ -15,7 +15,6 @@ use AppBundle\Entity\User;
use Avanzu\AdminThemeBundle\Event\ShowUserEvent; use Avanzu\AdminThemeBundle\Event\ShowUserEvent;
use Avanzu\AdminThemeBundle\Model\UserModel; use Avanzu\AdminThemeBundle\Model\UserModel;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/** /**
* Class NavbarShowUserListener * Class NavbarShowUserListener

View File

@@ -60,7 +60,9 @@ class RedirectToPreferredLocaleListener
$this->defaultLocale = $defaultLocale ?: $this->locales[0]; $this->defaultLocale = $defaultLocale ?: $this->locales[0];
if (!in_array($this->defaultLocale, $this->locales)) { 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, // 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) public function buildForm(FormBuilderInterface $builder, array $options)
{ {
$roles = [];
foreach ($this->roles as $key => $value) { foreach ($this->roles as $key => $value) {
$roles[$key] = $key; $roles[$key] = $key;
foreach ($value as $value2) { foreach ($value as $value2) {

View File

@@ -11,14 +11,12 @@
namespace AppBundle\Repository; namespace AppBundle\Repository;
use AppBundle\Entity\User; use AppBundle\Repository\Query\BaseQuery;
use TimesheetBundle\Entity\Activity;
use TimesheetBundle\Entity\Timesheet;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query; use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Adapter\DoctrineORMAdapter; use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta; use Pagerfanta\Pagerfanta;
use TimesheetBundle\Model\ActivityStatistic;
/** /**
* Class AbstractRepository * Class AbstractRepository
@@ -27,6 +25,19 @@ use TimesheetBundle\Model\ActivityStatistic;
*/ */
abstract class AbstractRepository extends EntityRepository 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 * @param Query $query

View File

@@ -22,6 +22,9 @@ class BaseQuery
const DEFAULT_PAGESIZE = 25; const DEFAULT_PAGESIZE = 25;
const DEFAULT_PAGE = 1; const DEFAULT_PAGE = 1;
const RESULT_TYPE_PAGER = 'PagerFanta';
const RESULT_TYPE_QUERYBUILDER = 'QueryBuilder';
/** /**
* @var int * @var int
*/ */
@@ -38,6 +41,10 @@ class BaseQuery
* @var string * @var string
*/ */
protected $order = 'ASC'; protected $order = 'ASC';
/**
* @var string
*/
protected $resultType = self::RESULT_TYPE_PAGER;
/** /**
* @return int * @return int
@@ -116,4 +123,24 @@ class BaseQuery
} }
return $this; 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 AppBundle\Utils\Markdown;
use Symfony\Component\Intl\Intl; use Symfony\Component\Intl\Intl;
use DateTime;
use DateInterval;
use TimesheetBundle\Entity\Customer; use TimesheetBundle\Entity\Customer;
use TimesheetBundle\Entity\Timesheet; use TimesheetBundle\Entity\Timesheet;
@@ -31,19 +29,19 @@ class Extensions extends \Twig_Extension
private $parser; private $parser;
/** /**
* @var array * @var string[]
*/ */
private $locales; private $locales;
/** /**
* Extensions constructor. * Extensions constructor.
* @param Markdown $parser * @param Markdown $parser
* @param $locales * @param string $locales
*/ */
public function __construct(Markdown $parser, $locales) public function __construct(Markdown $parser, $locales)
{ {
$this->parser = $parser; $this->parser = $parser;
$this->locales = $locales; $this->locales = explode('|', $locales);
} }
/** /**
@@ -158,11 +156,9 @@ class Extensions extends \Twig_Extension
*/ */
public function getLocales() public function getLocales()
{ {
$localeCodes = explode('|', $this->locales);
$locales = []; $locales = [];
foreach ($localeCodes as $localeCode) { foreach ($this->locales as $locale) {
$locales[] = ['code' => $localeCode, 'name' => Intl::getLocaleBundle()->getLocaleName($localeCode, $localeCode)]; $locales[] = ['code' => $locale, 'name' => Intl::getLocaleBundle()->getLocaleName($locale, $locale)];
} }
return $locales; return $locales;

View File

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

View File

@@ -36,7 +36,10 @@ class UserVoter extends AbstractVoter
*/ */
protected function supports($attribute, $subject) 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; return false;
} }
@@ -71,7 +74,7 @@ class UserVoter extends AbstractVoter
case self::CREATE: case self::CREATE:
// create actually passes in the current user as $subject, not the new one // create actually passes in the current user as $subject, not the new one
case self::DELETE: 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: case self::ROLES:
return $this->canAdminUsers($token); return $this->canAdminUsers($token);
} }

View File

@@ -44,7 +44,8 @@ class ActivityController extends Controller
public function recentActivitiesAction() public function recentActivitiesAction()
{ {
$user = $this->getUser(); $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( return $this->render(
'TimesheetBundle:Navbar:recent-activities.html.twig', 'TimesheetBundle:Navbar:recent-activities.html.twig',

View File

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

View File

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

View File

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

View File

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

View File

@@ -89,7 +89,13 @@ class TimesheetController extends AbstractController
// make sure only ADMIN can stop other users entries // make sure only ADMIN can stop other users entries
if ($user->getId() !== $entry->getUser()->getId()) { 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 { try {
@@ -141,7 +147,13 @@ class TimesheetController extends AbstractController
// make sure only ADMIN can edit other users entries // make sure only ADMIN can edit other users entries
if ($user->getId() !== $entry->getUser()->getId()) { 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')); $editForm = $this->createEditForm($entry, $request->get('page'));
@@ -155,9 +167,7 @@ class TimesheetController extends AbstractController
$this->flashSuccess('action.updated_successfully'); $this->flashSuccess('action.updated_successfully');
return $this->redirectToRoute( return $this->redirectToRoute('timesheet_paginated', ['page' => $request->get('page')]);
'timesheet_paginated', ['page' => $request->get('page')]
);
} }
return $this->render( return $this->render(

View File

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

View File

@@ -12,7 +12,6 @@
namespace TimesheetBundle\DataFixtures\ORM; namespace TimesheetBundle\DataFixtures\ORM;
use AppBundle\Entity\User; use AppBundle\Entity\User;
use Symfony\Component\Intl\Intl;
use TimesheetBundle\Entity\Activity; use TimesheetBundle\Entity\Activity;
use TimesheetBundle\Entity\Customer; use TimesheetBundle\Entity\Customer;
use TimesheetBundle\Entity\Project; use TimesheetBundle\Entity\Project;
@@ -30,8 +29,7 @@ use AppBundle\DataFixtures\ORM\LoadFixtures as AppBundleLoadFixtures;
*/ */
class LoadFixtures extends AppBundleLoadFixtures class LoadFixtures extends AppBundleLoadFixtures
{ {
const AMOUNT_ACTIVITIES = 10; // maximum activites per project const AMOUNT_TIMESHEET = 5000; // timesheet entries total
const AMOUNT_TIMESHEET = 1000; // timesheet entries total
const RATE_MIN = 10; // minimum rate for one hour const RATE_MIN = 10; // minimum rate for one hour
const RATE_MAX = 80; // maximum 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); $allUser = $this->getAllUsers($manager);
$amountUser = count($allUser); $amountUser = count($allUser);
$allActivity = $this->getAllActivities($manager); $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++) { for ($i = 0; $i <= self::AMOUNT_TIMESHEET; $i++) {
$entry = $this->createTimesheetEntry( $entry = $this->createTimesheetEntry(
$allUser[rand(1, $amountUser)], $allUser[rand(1, $amountUser)],
@@ -123,15 +123,19 @@ class LoadFixtures extends AppBundleLoadFixtures
$manager->persist($entry); $manager->persist($entry);
} }
// leave one running time entry for each user // by using array_pop we make sure that at least one user has NO running entry!
for ($i = 1; $i <= $amountUser; $i++) { array_pop($allUser);
// create active recodinge for test user
foreach ($allUser as $id => $user) {
for ($i = 0; $i < rand(1, 4); $i++) {
$entry = $this->createTimesheetEntry( $entry = $this->createTimesheetEntry(
$allUser[$i], $user,
$allActivity[array_rand($allActivity)] $allActivity[array_rand($allActivity)]
); );
$manager->persist($entry); $manager->persist($entry);
} }
}
$manager->flush(); $manager->flush();
} }
@@ -174,17 +178,18 @@ class LoadFixtures extends AppBundleLoadFixtures
$amountTimezone = count($allTimezones); $amountTimezone = count($allTimezones);
$allCustomer = $this->getCustomers(); $allCustomer = $this->getCustomers();
$amountCustomer = count($allCustomer); shuffle($allCustomer);
$i = 0;
for ($i = 0; $i < $amountCustomer; $i++) { foreach ($allCustomer as $customerName) {
$entry = new Customer(); $entry = new Customer();
$entry $entry
->setCurrency($this->getRandomCurrency()) ->setCurrency($this->getRandomCurrency())
->setVat(rand(0, 30)) ->setVat(rand(0, 30))
->setName($allCustomer[$i]) ->setName($customerName)
->setAddress($this->getRandomLocation()) ->setAddress($this->getRandomLocation())
->setComment($this->getRandomPhrase()) ->setComment($this->getRandomPhrase())
->setVisible($i % 3 != 0) ->setVisible($i++ % 3 != 0)
->setTimezone($allTimezones[rand(1, $amountTimezone)]); ->setTimezone($allTimezones[rand(1, $amountTimezone)]);
$manager->persist($entry); $manager->persist($entry);
@@ -195,20 +200,22 @@ class LoadFixtures extends AppBundleLoadFixtures
private function loadProjects(ObjectManager $manager) private function loadProjects(ObjectManager $manager)
{ {
$allCustomer = $this->getAllCustomers($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 $entry
->setName($this->getRandomProject()) ->setName($this->getRandomProject())
->setBudget(rand(1000, 100000)) ->setBudget(rand(500, 100000))
->setComment($this->getRandomPhrase()) ->setComment($this->getRandomPhrase())
->setCustomer($allCustomer[($i % $amountCustomer) + 1]) ->setCustomer($customer)
->setVisible($i % 3 != 0); ->setVisible($i % 3 != 0);
$manager->persist($entry); $manager->persist($entry);
} }
}
$manager->flush(); $manager->flush();
} }
@@ -217,7 +224,7 @@ class LoadFixtures extends AppBundleLoadFixtures
$allProject = $this->getAllProjects($manager); $allProject = $this->getAllProjects($manager);
foreach ($allProject as $projectId => $project) { foreach ($allProject as $projectId => $project) {
$activityCount = rand(1, self::AMOUNT_ACTIVITIES); $activityCount = rand(0, 10);
for ($i = 0; $i < $activityCount; $i++) { for ($i = 0; $i < $activityCount; $i++) {
$entry = new Activity(); $entry = new Activity();
$entry $entry
@@ -238,12 +245,11 @@ class LoadFixtures extends AppBundleLoadFixtures
private function getActivities() private function getActivities()
{ {
return [ return [
'Design', 'Designing',
'Programming', 'Programming',
'Testing', 'Testing',
'Documentation', 'Documentation',
'Pause', 'Pause',
'Internal',
'Research', 'Research',
'Meeting', 'Meeting',
'Hosting', 'Hosting',
@@ -256,6 +262,11 @@ class LoadFixtures extends AppBundleLoadFixtures
'Management', 'Management',
'Setup', 'Setup',
'Planning', 'Planning',
'Skiing',
'Eating',
'Watching TV',
'Talking',
'Cooking'
]; ];
} }
@@ -282,8 +293,10 @@ class LoadFixtures extends AppBundleLoadFixtures
'Hosting & Server', 'Hosting & Server',
'Customer Relations', 'Customer Relations',
'Infrastructure', 'Infrastructure',
'Princess Cat',
'Software Upgrade', 'Software Upgrade',
'Office Management', 'Office Management',
'Project X',
]; ];
} }
@@ -310,10 +323,20 @@ class LoadFixtures extends AppBundleLoadFixtures
'Amsterdam', 'Amsterdam',
'London', 'London',
'San Francisco', 'San Francisco',
'Tokio', 'Tokyo',
'Berlin', 'Berlin',
'Sao Paulo', 'Sao Paulo',
'Mexico City', 'Mexico City',
'Moscow',
'Sankt Petersburg',
'Taiwan',
'Perth',
'Sydney',
'Mumbai',
'Lagos',
'Karachi',
'Shanghai',
'Delhi',
]; ];
} }
@@ -342,18 +365,34 @@ class LoadFixtures extends AppBundleLoadFixtures
'Twitter', 'Twitter',
'Zend', 'Zend',
'SensioLabs', '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[] * @return string[]
*/ */

View File

@@ -71,7 +71,11 @@ class Project
/** /**
* @var Activity[] * @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; private $activities;

View File

@@ -18,7 +18,13 @@ use Doctrine\ORM\Mapping as ORM;
* Timesheet entity. * Timesheet entity.
* *
* @ORM\Entity(repositoryClass="TimesheetBundle\Repository\TimesheetRepository") * @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> * @author Kevin Papst <kevin@kevinpapst.de>
*/ */

View File

@@ -15,6 +15,8 @@ use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use TimesheetBundle\Entity\Activity; use TimesheetBundle\Entity\Activity;
use TimesheetBundle\Repository\ActivityRepository;
use TimesheetBundle\Repository\Query\ActivityQuery;
/** /**
* Custom form field type to select an activity. * Custom form field type to select an activity.
@@ -24,17 +26,6 @@ use TimesheetBundle\Entity\Activity;
class ActivityType extends AbstractType 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} * {@inheritdoc}
*/ */
@@ -44,7 +35,14 @@ class ActivityType extends AbstractType
'class' => 'TimesheetBundle:Activity', 'class' => 'TimesheetBundle:Activity',
'choice_label' => 'name', 'choice_label' => 'name',
'choice_value' => 'id', '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\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use TimesheetBundle\Entity\Project; use TimesheetBundle\Entity\Project;
use TimesheetBundle\Repository\ProjectRepository;
use TimesheetBundle\Repository\Query\ProjectQuery;
/** /**
* Custom form field type to select a project. * Custom form field type to select a project.
@@ -33,9 +35,14 @@ class ProjectType extends AbstractType
'class' => 'TimesheetBundle:Project', 'class' => 'TimesheetBundle:Project',
'choice_label' => 'name', 'choice_label' => 'name',
'choice_value' => 'id', 'choice_value' => 'id',
'group_by' => function(Project $project, $key, $index) { 'group_by' => function (Project $project, $key, $index) {
return $project->getCustomer()->getName(); 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 = []; $activities = [];
/* @var Timesheet $entry */ /* @var Timesheet $entry */
foreach($results as $entry) { foreach ($results as $entry) {
$activities[] = $entry->getActivity(); $activities[] = $entry->getActivity();
} }
@@ -93,10 +93,9 @@ class ActivityRepository extends AbstractRepository
return $stats; return $stats;
} }
/** /**
* @param ActivityQuery $query * @param ActivityQuery $query
* @return \Pagerfanta\Pagerfanta * @return \Doctrine\ORM\QueryBuilder|\Pagerfanta\Pagerfanta
*/ */
public function findByQuery(ActivityQuery $query) public function findByQuery(ActivityQuery $query)
{ {
@@ -116,6 +115,6 @@ class ActivityRepository extends AbstractRepository
// TODO check for visibility of customer and project // 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 * @param CustomerQuery $query
* @return \Pagerfanta\Pagerfanta * @return \Doctrine\ORM\QueryBuilder|\Pagerfanta\Pagerfanta
*/ */
public function findByQuery(CustomerQuery $query) public function findByQuery(CustomerQuery $query)
{ {
@@ -68,6 +68,6 @@ class CustomerRepository extends AbstractRepository
$qb->andWhere('c.visible = 0'); $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 * @param ProjectQuery $query
* @return \Pagerfanta\Pagerfanta * @return \Doctrine\ORM\QueryBuilder|\Pagerfanta\Pagerfanta
*/ */
public function findByQuery(ProjectQuery $query) public function findByQuery(ProjectQuery $query)
{ {
$qb = $this->getEntityManager()->createQueryBuilder(); $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') $qb->select('p', 'c')
->from('TimesheetBundle:Project', 'p') ->from('TimesheetBundle:Project', 'p')
->join('p.customer', 'c') ->join('p.customer', 'c')
@@ -72,6 +73,6 @@ class ProjectRepository extends AbstractRepository
// TODO check for visibility of customer // 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; return $this;
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
namespace AppBundle\Tests\Controller; namespace KimaiTest\AppBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
@@ -25,6 +25,7 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
* $ cd your-symfony-project/ * $ cd your-symfony-project/
* $ phpunit -c app * $ phpunit -c app
* *
* @group integration
*/ */
class DefaultControllerTest extends WebTestCase 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; color: #dd4b39;
} }
.navbar-nav>.messages-menu>.dropdown-menu>li .menu>li>a>p {
overflow: hidden;
text-overflow: ellipsis;
}
/* /*
.ticktac:hover i.running{ .ticktac:hover i.running{
color: #4ff131; color: #4ff131;