Compare commits

...

29 Commits
1.1 ... 1.2

Author SHA1 Message Date
Kevin Papst
94f8159fdc prepare release 1.2 (#1059) 2019-08-27 18:37:42 +02:00
Kevin Papst
8fb0b1445a added korean translations (#1039) 2019-08-26 15:00:59 +02:00
Kevin Papst
15673555a2 added API functions to save meta fields (#1063) 2019-08-26 14:30:39 +02:00
Kevin Papst
ceeec7b8a6 improved invoice handling for fixed rates (#1058) 2019-08-24 00:02:16 +02:00
Kevin Papst
46e5650882 refactored invoice and export screens (#1046) 2019-08-22 18:56:21 +02:00
Kevin Papst
405ea0076b fix empty user language (#1054) 2019-08-21 11:16:44 +02:00
Kevin Papst
8cd7f7de3f do not delete team if linked entity is deleted (#1053) 2019-08-21 01:04:58 +02:00
Kevin Papst
7cd17154fc support creating new roles (#1050) 2019-08-20 14:02:15 +02:00
Kevin Papst
299fcb091a added documentation links to all screens (#1044) 2019-08-19 12:53:36 +02:00
Kevin Papst
025c6b0e7b added css class to target running records in timesheet tables (#1042) 2019-08-16 22:34:41 +02:00
Kevin Papst
388c3ec188 reduce field size to 150 chars to prevent index out of length (#1031) 2019-08-16 16:57:09 +02:00
Kevin Papst
b6d9bb0d2a fix team opens in modal (#1041) 2019-08-16 16:55:56 +02:00
Kevin Papst
561ec3b1e9 added team permissions (#996) 2019-08-16 01:22:33 +02:00
Kevin Papst
9611646bc6 added decimal format variable for invoice duration (#1037) 2019-08-15 21:31:13 +02:00
Kevin Papst
b5972bb680 added invoice variable to display month numerical (#1033) 2019-08-14 23:30:45 +02:00
Kevin Papst
455861ec69 copy timesheet meta fields on restart record (#1030) 2019-08-13 18:41:20 +02:00
trentks
5affa0de82 fix various minor english grammar translations (#1002) 2019-08-08 23:04:02 +02:00
Kevin Papst
e11a2a6089 upgraded FosUserBundle and Symfony 4.3 (#1005) 2019-08-02 16:52:30 +02:00
Kevin Papst
614a50ce0b added command to show plugin versions (#994) 2019-08-02 08:53:51 +02:00
Kevin Papst
66594b286a remove default role from user filter and role select (#993) 2019-08-02 08:31:45 +02:00
Kevin Papst
8e06f94bdd composer prod dependencies and commands (#986) 2019-08-01 23:21:43 +02:00
trentks
b0e03bb0b8 Fix english translation for 'info' (#995) 2019-08-01 23:01:02 +02:00
Kevin Papst
22e30b1871 fix activity import Kimai v1 (#1001) 2019-08-01 21:38:28 +02:00
Kevin Papst
8104a67e3e travis builds with mysql (#924) 2019-08-01 19:25:38 +02:00
Kevin Papst
654ffda454 respect users timezone when populating working-time dashboard widget (#988) 2019-07-30 23:57:20 +02:00
cdahinten
3f51437569 optimize DE translations (#950) 2019-07-29 13:31:00 +02:00
Kevin Papst
1bd784eef3 added calendar slot_duration config (#989) 2019-07-26 23:51:29 +02:00
yayitazale
0d11aded40 spanish update - more natural translations (#945) 2019-07-25 18:26:39 +02:00
Kevin Papst
00925d38aa fix migration for sqlite users (#985) 2019-07-25 18:00:53 +02:00
307 changed files with 10629 additions and 3346 deletions

View File

@@ -1,5 +1,5 @@
unreleased=true
future-release=1.1
future-release=1.2
exclude-labels=duplicate,question,invalid,wontfix,release
enhancement_labels=>enhancement,Enhancement,feature request,translation i18n,technical debt
issues-wo-labels=false

View File

@@ -1,29 +1,64 @@
dist: xenial
language: php
sudo: false
cache:
directories:
- $HOME/.composer/cache/files
addons:
mariadb: '10.2'
services:
- mysql
env:
global:
- CODECOVERAGE=0
matrix:
fast_finish: true
include:
- php: 7.2
- php: 7.3
env: CODECOVERAGE=1
- stage: Code quality
env: CODECOVERAGE=0 DB=sqlite
php: '7.2'
script:
- composer validate --no-check-all --strict
- composer kimai:codestyle
- composer kimai:phpstan
- stage: Test
php: '7.2'
env: CODECOVERAGE=0 DB=mysql
sudo: required
services:
- mysql
- stage: Test
php: '7.3'
env: CODECOVERAGE=0 DB=mysql
sudo: required
services:
- mysql
- stage: Test
php: '7.2'
env: CODECOVERAGE=0 DB=sqlite
- stage: Test
#php: '7.4snapshot'
php: '7.3'
env: CODECOVERAGE=0 DB=sqlite
- stage: Test
php: '7.2'
env: CODECOVERAGE=0 DB=mariadb
addons:
mariadb: '10.2'
services:
- mysql
before_script:
- mysql -u root -e 'CREATE USER IF NOT EXISTS travis@localhost; GRANT ALL ON *.* TO travis@localhost;'
- stage: Test
php: '7.3'
env: CODECOVERAGE=0 DB=mariadb
addons:
mariadb: '10.2'
services:
- mysql
before_script:
- mysql -u root -e 'CREATE USER IF NOT EXISTS travis@localhost; GRANT ALL ON *.* TO travis@localhost;'
- stage: Code coverage
php: '7.3'
env: CODECOVERAGE=1 DB=sqlite
after_success:
- if [[ $CODECOVERAGE == 1 ]]; then bash <(curl -s https://codecov.io/bash); fi
before_install:
# - phpenv config-rm xdebug.ini
- if [[ $CODECOVERAGE == 0 ]]; then phpenv config-rm xdebug.ini; fi;
- composer self-update
- php -i
- php -m
@@ -33,18 +68,11 @@ install:
- composer require zendframework/zend-ldap
script:
- composer validate --no-check-all --strict
- composer kimai:codestyle
- composer kimai:phpstan
- if [[ $CODECOVERAGE == 1 ]]; then vendor/bin/phpunit tests/ --coverage-clover=coverage.xml; else vendor/bin/phpunit tests/; fi;
- cp tests/.env.dist.sqlite .env
- bin/console doctrine:database:create -n
- bin/console doctrine:migrations:migrate -n
- bin/console doctrine:migrations:migrate first -n
- cp tests/.env.dist.mysql .env
- if [[ $CODECOVERAGE == 1 ]]; then vendor/bin/phpunit tests/ --coverage-clover=coverage.xml; fi;
- if [[ $CODECOVERAGE == 0 ]]; then vendor/bin/phpunit tests/; fi;
- if [[ $DB == 'sqlite' ]]; then cp tests/.env.dist.sqlite .env; fi;
- if [[ $DB == "mysql" || $DB == "mariadb" ]]; then cp tests/.env.dist.mysql .env; fi;
- bin/console doctrine:database:create -n
- bin/console doctrine:migrations:migrate -n
- bin/console doctrine:migrations:migrate first -n
after_success:
- if [[ $CODECOVERAGE == 1 ]]; then bash <(curl -s https://codecov.io/bash); fi

View File

@@ -1,5 +1,10 @@
# Change Log
## [1.2](https://github.com/kevinpapst/kimai2/tree/1.2) (2019-08-28)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.1...1.2)
Release notes including the changelog can be found [here](https://github.com/kevinpapst/kimai2/releases/tag/1.2)
## [1.1](https://github.com/kevinpapst/kimai2/tree/1.1) (2019-07-24)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.0.1...1.1)

View File

@@ -5,6 +5,7 @@
[![Travis Status](https://travis-ci.org/kevinpapst/kimai2.svg?branch=master)](https://travis-ci.org/kevinpapst/kimai2)
[![Code Coverage](https://codecov.io/gh/kevinpapst/kimai2/branch/master/graph/badge.svg)](https://codecov.io/gh/kevinpapst/kimai2)
[![Gitter](https://badges.gitter.im/kimai2/support.svg)](https://gitter.im/kimai2/support)
[![Bountysource](https://img.shields.io/bountysource/team/kimai2/activity)](https://www.bountysource.com/teams/kimai2)
Kimai is a free, open source and online time-tracking software designed for small businesses and freelancers.
It is built with modern technologies such as Symfony, Bootstrap, RESTful API, Doctrine, AdminLTE, Webpack, ES6 etc.

View File

@@ -7,8 +7,8 @@ As announced in the [README](README.md) I only support the latest available rele
| Version | Supported |
| ------- | ------------------ |
| master | :white_check_mark: |
| 1.1 | :white_check_mark: |
| < 1.1 | :x: |
| 1.2 | :white_check_mark: |
| < 1.2 | :x: |
## Reporting a Vulnerability

View File

@@ -5,10 +5,22 @@ _Make sure to create a backup before you start!_
Read the [updates documentation](https://www.kimai.org/documentation/updates.html) to find out how
you can upgrade your Kimai installation to the latest stable release.
**Before you leave ...**
Check below if there are more version specific steps required, which need to be executed after the normal update process.
Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation.
There might be more steps required which are version specific and need to be executed after [the update](https://www.kimai.org/documentation/updates.html).
Read and perform EACH version specific task below, otherwise you risk data inconsistency or a broken installation!
## [1.2](https://github.com/kevinpapst/kimai2/releases/tag/1.2)
### Possible BC breaks
- Deleted timezone conversion command. If you are still using 0.7 or below, you need to upgrade to 1.1 before upgrading to this version.
- Minimum password length raised from 5 to 8 character (applies only for password changes and new users)
- Maximum customer name length lowered to 150 character
- Maximum project name length lowered to 150 character
- Maximum activity name length lowered to 150 character
- Added new permission: `manage_invoice_template`
- Removed permissions: `view_invoice_template`, `create_invoice_template`, `edit_invoice_template`, `delete_invoice_template`
- Removed permission: `view_invoice` (using `create_export` only)
- Custom export renderer need to check for usage of `Timesheet::getEnd()` as running entries can now be exported as well
## [1.1](https://github.com/kevinpapst/kimai2/releases/tag/1.1)

View File

@@ -15,16 +15,18 @@ require('bootstrap-select');
const Moment = require('moment');
global.moment = Moment;
require('moment/locale/de');
require('moment/locale/it');
require('moment/locale/fr');
require('moment/locale/es');
require('moment/locale/ru');
require('moment/locale/ar');
require('moment/locale/de');
require('moment/locale/es');
require('moment/locale/fr');
require('moment/locale/hu');
require('moment/locale/pt-br');
require('moment/locale/sv');
require('moment/locale/it');
require('moment/locale/ja');
require('moment/locale/ko');
require('moment/locale/pt-br');
require('moment/locale/ru');
require('moment/locale/sk');
require('moment/locale/sv');
require('daterangepicker');

View File

@@ -19,7 +19,7 @@ if (!isset($_SERVER['APP_ENV'])) {
if (!class_exists(Dotenv::class)) {
throw new \RuntimeException('APP_ENV environment variable is not defined. You need to define environment variables for configuration or add "symfony/dotenv" as a Composer dependency to load variables from a .env file.');
}
(new Dotenv())->load(__DIR__.'/../.env');
(new Dotenv(true))->load(__DIR__.'/../.env');
}
$input = new ArgvInput();

View File

@@ -18,11 +18,9 @@
"ext-pdo": "*",
"ext-zip": "*",
"beberlei/doctrineextensions": "^1.2",
"doctrine/doctrine-fixtures-bundle": "^3.0",
"erusev/parsedown": "^1.6",
"friendsofsymfony/rest-bundle": "^2.3",
"friendsofsymfony/user-bundle": "~2.0",
"fzaninotto/faker": "^1.8",
"friendsofsymfony/rest-bundle": "^2.5",
"friendsofsymfony/user-bundle": "dev-master",
"gedmo/doctrine-extensions": "^2.4",
"jms/metadata": "^2.0",
"jms/serializer-bundle": "^3.2",
@@ -45,18 +43,15 @@
"symfony/form": "^4.0",
"symfony/framework-bundle": "^4.0",
"symfony/intl": "^4.0",
"symfony/maker-bundle": "^1.0",
"symfony/monolog-bundle": "^3.1",
"symfony/monolog-bundle": "^3.4",
"symfony/orm-pack": "^1.0",
"symfony/profiler-pack": "^1.0",
"symfony/security-bundle": "~4.2.0",
"symfony/security-bundle": "^4.0",
"symfony/security-csrf": "^4.0",
"symfony/serializer": "^4.0",
"symfony/swiftmailer-bundle": "^3.1",
"symfony/swiftmailer-bundle": "^3.2",
"symfony/translation": "^4.0",
"symfony/twig-bundle": "^4.0",
"symfony/validator": "^4.0",
"symfony/web-server-bundle": "^4.0",
"symfony/webpack-encore-bundle": "^1.5",
"symfony/yaml": "^4.0",
"twig/extensions": "^1.5",
@@ -64,7 +59,9 @@
},
"require-dev": {
"dama/doctrine-test-bundle": "^5.0",
"doctrine/doctrine-fixtures-bundle": "^3.2",
"friendsofphp/php-cs-fixer": "^2.10",
"fzaninotto/faker": "^1.8",
"phpstan/phpstan": "^0.11.7",
"phpstan/phpstan-doctrine": "^0.11.4",
"phpstan/phpstan-phpunit": "^0.11.2",
@@ -72,8 +69,11 @@
"phpunit/phpunit": "^7.0",
"symfony/browser-kit": "^4.0",
"symfony/css-selector": "^4.0",
"symfony/maker-bundle": "^1.12",
"symfony/phpunit-bridge": "^4.0",
"symfony/thanks": "^1.0"
"symfony/profiler-pack": "^1.0",
"symfony/thanks": "^1.0",
"symfony/web-server-bundle": "~4.2.0"
},
"config": {
"platform": {
@@ -138,7 +138,7 @@
"symfony": {
"id": "01C3FWRDJJEX9K6Y3A4XDFXPBR",
"allow-contrib": true,
"require": "~4.2.0"
"require": "4.3.*"
}
}
}

2097
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -84,23 +84,33 @@ kimai:
# mapping complex rule sets of single permissions to named "sets" ("set name" = [array of "permissions and sets"])
sets:
ACTIVITIES: ['view_activity','create_activity','edit_activity','budget_activity','delete_activity']
PROJECTS: ['view_project','create_project','edit_project','budget_project','delete_project']
CUSTOMERS: ['view_customer','create_customer','edit_customer','budget_customer','delete_customer']
ACTIVITIES_TEAM: ['view_activity','create_activity','edit_teamlead_activity','budget_teamlead_activity']
PROJECTS: ['view_project','create_project','edit_project','budget_project','delete_project','permissions_project']
PROJECTS_TEAM: ['view_project','edit_teamlead_project','budget_teamlead_project','permissions_teamlead_project']
CUSTOMERS: ['view_customer','create_customer','edit_customer','budget_customer','delete_customer','permissions_customer']
CUSTOMERS_TEAM: ['view_customer','edit_teamlead_customer','budget_teamlead_customer']
INVOICE: ['view_invoice','create_invoice']
INVOICE_TEMPLATE: ['view_invoice_template','create_invoice_template','edit_invoice_template','delete_invoice_template']
INVOICE_TEMPLATE: ['manage_invoice_template']
TIMESHEET: ['view_own_timesheet','start_own_timesheet','stop_own_timesheet','create_own_timesheet','edit_own_timesheet','export_own_timesheet','delete_own_timesheet']
TIMESHEET_OTHER: ['view_other_timesheet','start_other_timesheet','stop_other_timesheet','create_other_timesheet','edit_other_timesheet','export_other_timesheet','delete_other_timesheet']
PROFILE: ['view_own_profile','edit_own_profile','password_own_profile','preferences_own_profile','api-token_own_profile']
PROFILE_OTHER: ['view_other_profile','edit_other_profile','delete_other_profile','password_other_profile','roles_other_profile','preferences_other_profile','api-token_other_profile']
PROFILE_OTHER: ['view_other_profile','edit_other_profile','delete_other_profile','password_other_profile','roles_other_profile','preferences_other_profile','api-token_other_profile','teams_other_profile']
TAGS: ['view_tag','delete_tag']
USER: ['view_user','create_user','delete_user','role_permissions']
RATE: ['view_rate_own_timesheet','edit_rate_own_timesheet']
RATE_OTHER: ['view_rate_other_timesheet','edit_rate_other_timesheet']
EXPORT: ['view_export','create_export','edit_export_own_timesheet','edit_export_other_timesheet']
EXPORT: ['create_export','edit_export_own_timesheet','edit_export_other_timesheet']
TEAMS: ['view_team','create_team','edit_team','delete_team']
# some single default definitions for roles
SINGLE_USER: []
SINGLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile']
SINGLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile']
SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','delete_own_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile']
# link above sets to one set for each user role
ROLE_USER: ['@TIMESHEET','@PROFILE']
ROLE_TEAMLEAD: ['@INVOICE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@EXPORT','@TAGS','@INVOICE_TEMPLATE','!delete_invoice_template','view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile']
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@RATE','@RATE_OTHER','@EXPORT','@TAGS','hourly-rate_own_profile','edit_exported_timesheet']
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@RATE','@RATE_OTHER','@EXPORT','@TAGS','hourly-rate_own_profile','hourly-rate_other_profile','delete_own_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet']
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAM','@PROJECTS_TEAM','@CUSTOMERS_TEAM','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@SINGLE_TEAMLEAD']
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_ADMIN']
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_SUPER_ADMIN']
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"])
maps:
ROLE_USER: ['ROLE_USER']
@@ -124,6 +134,7 @@ kimai:
# week_numbers: true
# weekends: true
# day_limit: 4
# slot_duration: '00:30:00'
# businessHours:
# days: [1, 2, 3, 4, 5]
# begin: '08:00'
@@ -153,18 +164,6 @@ kimai:
# --------------------------------------------------------------------------------
# --------------------------------------------------------------------------------
# THEME Settings
# will be available as twig globals at "kimai_context.*".
# See documentation at https://www.kimai.org/documentation/theme.html
# --------------------------------------------------------------------------------
# theme:
# # BETA test: If you set this to 'selectpicker' the customer/project/activity select boxes will be transformed
# # into a searchable and javascript enhanced input type
# select_type: ~
# --------------------------------------------------------------------------------
# --------------------------------------------------------------------------------
# DASHBOARD
# Using the configurable widget sections.
@@ -205,6 +204,12 @@ kimai:
# Locale specific settings, like date formats
# --------------------------------------------------------------------------------
languages:
ar:
date_time_type: 'yyyy-MM-dd HH:mm'
date_type: 'yyyy-MM-dd'
date: 'Y-m-d'
date_time: 'm-d H:i'
duration: '%%h:%%m h'
de:
date_time_type: 'dd.MM.yyyy HH:mm'
date_type: 'dd.MM.yyyy'
@@ -217,13 +222,7 @@ kimai:
date: 'Y-m-d'
date_time: 'm-d H:i'
duration: '%%h:%%m h'
pt_BR:
date_time_type: 'dd-MM-yyyy HH:mm'
date_type: 'dd-MM-yyyy'
date: 'd-m-Y'
date_time: 'd-m H:i'
duration: '%%h:%%m h'
it:
es:
date_time_type: 'dd.MM.yyyy HH:mm'
date_type: 'dd.MM.yyyy'
date: 'd.m.Y'
@@ -235,29 +234,47 @@ kimai:
date: 'd/m/Y'
date_time: 'd/m H:i'
duration: '%%h h %%m'
es:
hu:
date_time_type: 'yyyy.MM.dd. HH:mm'
date_type: 'yyyy.MM.dd.'
date: 'Y.m.d.'
date_time: 'm.d. H:i'
duration: '%%h:%%m h'
it:
date_time_type: 'dd.MM.yyyy HH:mm'
date_type: 'dd.MM.yyyy'
date: 'd.m.Y'
date_time: 'd.m. H:i'
duration: '%%h:%%m h'
ja:
date_time_type: 'yyyy-MM-dd HH:mm'
date_type: 'yyyy-MM-dd'
date: 'Y-m-d'
date_time: 'm-d H:i'
duration: '%%h:%%m tim'
ko:
date_time_type: 'yyyy-MM-dd HH:mm'
date_type: 'yyyy-MM-dd'
date: 'Y-m-d'
date_time: 'm-d H:i'
duration: '%%h:%%m h'
pt_BR:
date_time_type: 'dd-MM-yyyy HH:mm'
date_type: 'dd-MM-yyyy'
date: 'd-m-Y'
date_time: 'd-m H:i'
duration: '%%h:%%m h'
ru:
date_time_type: 'dd.MM.yyyy HH:mm'
date_type: 'dd.MM.yyyy'
date: 'd.m.Y'
date_time: 'd.m. H:i'
duration: '%%h:%%m h'
ar:
date_time_type: 'yyyy-MM-dd HH:mm'
date_type: 'yyyy-MM-dd'
date: 'Y-m-d'
date_time: 'm-d H:i'
duration: '%%h:%%m h'
hu:
date_time_type: 'yyyy.MM.dd. HH:mm'
date_type: 'yyyy.MM.dd.'
date: 'Y.m.d.'
date_time: 'm.d. H:i'
sk:
date_time_type: 'dd. MM. yyyy HH:mm'
date_type: 'dd. MM. yyyy'
date: 'd. m. Y'
date_time: 'd. m. H:i'
duration: '%%h:%%m h'
sv:
date_time_type: 'yyyy-MM-dd HH:mm'
@@ -265,35 +282,6 @@ kimai:
date: 'Y-m-d'
date_time: 'm-d H:i'
duration: '%%h:%%m tim'
ja:
date_time_type: 'yyyy-MM-dd HH:mm'
date_type: 'yyyy-MM-dd'
date: 'Y-m-d'
date_time: 'm-d H:i'
duration: '%%h:%%m tim'
sk:
date_time_type: 'dd. MM. yyyy HH:mm'
date_type: 'dd. MM. yyyy'
date: 'd. m. Y'
date_time: 'd. m. H:i'
duration: '%%h:%%m h'
# --------------------------------------------------------------------------------
# --------------------------------------------------------------------------------
# FORMS
# Default settings used to populate forms
# --------------------------------------------------------------------------------
# defaults:
# customer:
# timezone: Europe/Berlin
# country: DE
# currency: EUR
# user:
# timezone: Europe/Berlin
# language: de
# theme: blue-light
# --------------------------------------------------------------------------------

View File

@@ -21,6 +21,8 @@ nelmio_api_doc:
- { alias: TimesheetSubCollection, type: App\Entity\Timesheet, groups: [Default, Subresource, Timesheet] }
- { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User] }
- { alias: UserCollection, type: App\Entity\User, groups: [Default, Collection, User] }
- { alias: TeamEntity, type: App\Entity\Team, groups: [Default, Entity, Team] }
- { alias: TeamCollection, type: App\Entity\Team, groups: [Default, Collection, Team] }
- { alias: I18nConfig, type: App\API\Model\I18n, groups: [Default] }
areas:
path_patterns:

View File

@@ -1,6 +1,6 @@
security:
encoders:
App\Entity\User: bcrypt
App\Entity\User: auto
providers:
chain_provider:

View File

@@ -4,7 +4,7 @@ security:
encoders:
# to make tests much faster, BCrypt cost is changed to its minimum allowed value (4)
# See https://symfony.com/doc/current/reference/configuration/security.html#using-the-bcrypt-password-encoder
App\Entity\User: { algorithm: bcrypt, cost: 4 }
App\Entity\User: { algorithm: auto }
firewalls:
secured_area:

View File

@@ -0,0 +1,14 @@
App\Entity\Team:
exclusion_policy: All
custom_accessor_order: [id, name, users]
properties:
id:
include: true
name:
include: true
users:
exclude: true
customers:
exclude: true
projects:
exclude: true

View File

@@ -2,7 +2,7 @@
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters:
locale: en
app_locales: en|de|it|fr|es|ru|ar|hu|pt_BR|sv|ja|sk
app_locales: ar|de|en|es|fr|hu|it|ja|ko|pt_BR|ru|sk|sv
services:
# default configuration for services in *this* file
@@ -130,7 +130,8 @@ services:
arguments: ['%security.role_hierarchy.roles%']
App\Security\RolePermissionManager:
arguments: ['%kimai.permissions%']
arguments:
$permissions: '%kimai.permissions%'
# ================================================================================
# LDAP
@@ -154,6 +155,11 @@ services:
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\User']
App\Repository\TeamRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Team']
App\Repository\ActivityRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]

View File

@@ -9,5 +9,5 @@ App\Entity\User:
- NotBlank: ~
- Email: ~
plainPassword:
- NotBlank: { groups: [registration, passwordUpdate] }
- Length: { min: 5, max: 60, groups: [registration, passwordUpdate] }
- NotBlank: { groups: [Registration, PasswordUpdate] }
- Length: { min: 8, max: 60, groups: [Registration, PasswordUpdate] }

View File

@@ -3,6 +3,7 @@ includes:
- vendor/phpstan/phpstan-doctrine/extension.neon
parameters:
tmpDir: %rootDir%/../../../var/cache/phpstan
autoload_directories:
- %rootDir%/../../../src/Migrations
# symfony:
@@ -16,6 +17,7 @@ parameters:
- '#Access to an undefined property Faker\\Generator::\$stateAbbr.#'
- '#Access to an undefined property Faker\\Generator::\$catchPhrase.#'
- '#Access to an undefined property Faker\\Generator::\$bs.#'
- '#Method Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface::dispatch\(\) invoked with 2 parameters, 1 required.#'
excludes_analyse:
- %rootDir%/../../../src/Command/KimaiImporterCommand.php
- %rootDir%/../../../src/Ldap/LdapDriver.php

View File

@@ -5,6 +5,15 @@
# to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl).
DirectoryIndex index.php
# Do NOT use Kimai with http, this is a huge security risk!
# Activate these lines if you configured SSL and certificates:
#<IfModule mod_rewrite.c>
# RewriteEngine On
# RewriteCond %{SERVER_PORT} 80
# RewriteRule ^(.*)$ https://kimai.example.com/$1 [R,L]
#</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,29 +2,29 @@
"entrypoints": {
"app": {
"js": [
"build/runtime.784b5d0d.js",
"build/0.66b5fd0b.js",
"build/1.93f0cf2b.js",
"build/app.3f742878.js"
"build/runtime.4ee6be68.js",
"build/0.a87622f3.js",
"build/1.c1bee41f.js",
"build/app.98c93d11.js"
],
"css": [
"build/app.7f4a18a1.css"
"build/app.e8d9cb98.css"
]
},
"chart": {
"js": [
"build/runtime.784b5d0d.js",
"build/0.66b5fd0b.js",
"build/2.c06cd055.js",
"build/chart.2bb71e2e.js"
"build/runtime.4ee6be68.js",
"build/0.a87622f3.js",
"build/2.7be60d8d.js",
"build/chart.0af3f813.js"
]
},
"calendar": {
"js": [
"build/runtime.784b5d0d.js",
"build/0.66b5fd0b.js",
"build/1.93f0cf2b.js",
"build/calendar.59417f4c.js"
"build/runtime.4ee6be68.js",
"build/0.a87622f3.js",
"build/1.c1bee41f.js",
"build/calendar.e654bdd4.js"
],
"css": [
"build/calendar.b0551848.css"
@@ -32,14 +32,14 @@
}
},
"integrity": {
"build/runtime.784b5d0d.js": "sha384-+AsC7zoefPga1AkczlNSCXcbGwD0TwokkldvNISHf1nMlOpafanLjtKvlZy9cVsm",
"build/0.66b5fd0b.js": "sha384-ncT/BKhCsqH6jhxwdsSG95m1ei7ZZjeZtzH1262h+OPUU80TSFFE3dt+abcHHMok",
"build/1.93f0cf2b.js": "sha384-pmnwzvj+UJhDD3uDf+ekLARMBRtMDxWGbnJDrJvwIoRZd3MW/tOGzAW5Ybhbvw20",
"build/app.3f742878.js": "sha384-7221dBEmG8aOmSRCEjuKlrVH47VH1bdnmdEC2pb9y1d9Jtbg6d2t2HfyV3Wa34TI",
"build/app.7f4a18a1.css": "sha384-p8Dw7CP1RxxJ2K5yuaNpCw8r1lKd64DlVA2IQeEGxBAC2QxOjweafd8w9CSMk0GC",
"build/2.c06cd055.js": "sha384-txR0QG+838LKYtPQ99Gx4OU7WmgN9J3joZEyGwIskSz74EN1T4/IBVnmNaKiFN1q",
"build/chart.2bb71e2e.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",
"build/calendar.59417f4c.js": "sha384-16sOZQy1UmOaoLAVFPfHnF8nFqUxhc4URAg+ypJnnab8uqZAGHDfFFrnq0/6PXl1",
"build/runtime.4ee6be68.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
"build/0.a87622f3.js": "sha384-ncT/BKhCsqH6jhxwdsSG95m1ei7ZZjeZtzH1262h+OPUU80TSFFE3dt+abcHHMok",
"build/1.c1bee41f.js": "sha384-7UVWcP6Hefp2k/CrtGSITKXx4dSZqtvpAiU8WX7dClETkzMewrUjoCRtVXQ5j3KI",
"build/app.98c93d11.js": "sha384-z6f94h/RD/8JXo1OWkUp7g6TXXV59JW70WA/SdusZh02FcwRvvBIX21k6O6pUBfw",
"build/app.e8d9cb98.css": "sha384-UuTUHbhvRNk3t+1gL0JNACbAl5xXH8OhD9ah7jaXDbd32z8dNCjK+5oJSQF77rmz",
"build/2.7be60d8d.js": "sha384-txR0QG+838LKYtPQ99Gx4OU7WmgN9J3joZEyGwIskSz74EN1T4/IBVnmNaKiFN1q",
"build/chart.0af3f813.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",
"build/calendar.e654bdd4.js": "sha384-TYdXaKv0q95Z4i/TFUaFHzMOZ0N6T4iuV92ejc0BX7Rpbd0p55ZhbgriPSn5pVcK",
"build/calendar.b0551848.css": "sha384-ta519W5+NY1a8FBh7rD4zORYTfUXCYTLci0Z25nf4BfodmuXqyPVNEe7dSe2/fRd"
}
}

View File

@@ -1,13 +1,13 @@
{
"build/0.66b5fd0b.js": "build/0.66b5fd0b.js",
"build/1.93f0cf2b.js": "build/1.93f0cf2b.js",
"build/2.c06cd055.js": "build/2.c06cd055.js",
"build/app.css": "build/app.7f4a18a1.css",
"build/app.js": "build/app.3f742878.js",
"build/0.a87622f3.js": "build/0.a87622f3.js",
"build/1.c1bee41f.js": "build/1.c1bee41f.js",
"build/2.7be60d8d.js": "build/2.7be60d8d.js",
"build/app.css": "build/app.e8d9cb98.css",
"build/app.js": "build/app.98c93d11.js",
"build/calendar.css": "build/calendar.b0551848.css",
"build/calendar.js": "build/calendar.59417f4c.js",
"build/chart.js": "build/chart.2bb71e2e.js",
"build/runtime.js": "build/runtime.784b5d0d.js",
"build/calendar.js": "build/calendar.e654bdd4.js",
"build/chart.js": "build/chart.0af3f813.js",
"build/runtime.js": "build/runtime.4ee6be68.js",
"build/fonts/fa-brands-400.eot": "build/fonts/fa-brands-400.8e49b728.eot",
"build/fonts/fa-brands-400.woff": "build/fonts/fa-brands-400.9f018d10.woff",
"build/fonts/fa-brands-400.woff2": "build/fonts/fa-brands-400.9f4ce3dc.woff2",

View File

@@ -0,0 +1 @@
!function(e){function r(r){for(var n,l,i=r[0],f=r[1],a=r[2],c=0,s=[];c<i.length;c++)l=i[c],Object.prototype.hasOwnProperty.call(o,l)&&o[l]&&s.push(o[l][0]),o[l]=0;for(n in f)Object.prototype.hasOwnProperty.call(f,n)&&(e[n]=f[n]);for(p&&p(r);s.length;)s.shift()();return u.push.apply(u,a||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,i=1;i<t.length;i++){var f=t[i];0!==o[f]&&(n=!1)}n&&(u.splice(r--,1),e=l(l.s=t[0]))}return e}var n={},o={runtime:0},u=[];function l(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,l),t.l=!0,t.exports}l.m=e,l.c=n,l.d=function(e,r,t){l.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},l.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},l.t=function(e,r){if(1&r&&(e=l(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(l.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)l.d(t,n,function(r){return e[r]}.bind(null,n));return t},l.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return l.d(r,"a",r),r},l.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},l.p="build/";var i=window.webpackJsonp=window.webpackJsonp||[],f=i.push.bind(i);i.push=r,i=i.slice();for(var a=0;a<i.length;a++)r(i[a]);var p=f;t()}([]);

View File

@@ -1 +0,0 @@
!function(e){function r(r){for(var n,i,l=r[0],f=r[1],a=r[2],c=0,s=[];c<l.length;c++)i=l[c],o[i]&&s.push(o[i][0]),o[i]=0;for(n in f)Object.prototype.hasOwnProperty.call(f,n)&&(e[n]=f[n]);for(p&&p(r);s.length;)s.shift()();return u.push.apply(u,a||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,l=1;l<t.length;l++){var f=t[l];0!==o[f]&&(n=!1)}n&&(u.splice(r--,1),e=i(i.s=t[0]))}return e}var n={},o={runtime:0},u=[];function i(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,i),t.l=!0,t.exports}i.m=e,i.c=n,i.d=function(e,r,t){i.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},i.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,r){if(1&r&&(e=i(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(i.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)i.d(t,n,function(r){return e[r]}.bind(null,n));return t},i.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(r,"a",r),r},i.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},i.p="build/";var l=window.webpackJsonp=window.webpackJsonp||[],f=l.push.bind(l);l.push=r,l=l.slice();for(var a=0;a<l.length;a++)r(l[a]);var p=f;t()}([]);

View File

@@ -12,7 +12,7 @@ if (!isset($_SERVER['APP_ENV'])) {
if (!class_exists(Dotenv::class)) {
throw new \RuntimeException('APP_ENV environment variable is not defined. You need to define environment variables for configuration or add "symfony/dotenv" as a Composer dependency to load variables from a .env file.');
}
(new Dotenv())->load(__DIR__.'/../.env');
(new Dotenv(true))->load(__DIR__.'/../.env');
}
$env = $_SERVER['APP_ENV'] ?? 'dev';

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace App\API;
use App\Entity\Activity;
use App\Event\ActivityMetaDefinitionEvent;
use App\Form\API\ActivityApiEditForm;
use App\Repository\ActivityRepository;
use App\Repository\Query\ActivityQuery;
@@ -22,6 +23,7 @@ use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -36,16 +38,21 @@ class ActivityController extends BaseApiController
/**
* @var ActivityRepository
*/
protected $repository;
private $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
private $viewHandler;
/**
* @var EventDispatcherInterface
*/
private $dispatcher;
public function __construct(ViewHandlerInterface $viewHandler, ActivityRepository $repository)
public function __construct(ViewHandlerInterface $viewHandler, ActivityRepository $repository, EventDispatcherInterface $dispatcher)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
$this->dispatcher = $dispatcher;
}
/**
@@ -169,14 +176,15 @@ class ActivityController extends BaseApiController
$activity = new Activity();
$event = new ActivityMetaDefinitionEvent($activity);
$this->dispatcher->dispatch($event, ActivityMetaDefinitionEvent::class);
$form = $this->createForm(ActivityApiEditForm::class, $activity);
$form->submit($request->request->all());
if ($form->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($activity);
$entityManager->flush();
$this->repository->saveActivity($activity);
$view = new View($activity, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Activity']);
@@ -218,6 +226,8 @@ class ActivityController extends BaseApiController
* @param Request $request
* @param string $id
* @return Response
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function patchAction(Request $request, string $id)
{
@@ -231,6 +241,9 @@ class ActivityController extends BaseApiController
throw new AccessDeniedHttpException('User cannot update activity');
}
$event = new ActivityMetaDefinitionEvent($activity);
$this->dispatcher->dispatch($event, ActivityMetaDefinitionEvent::class);
$form = $this->createForm(ActivityApiEditForm::class, $activity);
$form->setData($activity);
@@ -243,13 +256,67 @@ class ActivityController extends BaseApiController
return $this->viewHandler->handle($view);
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($activity);
$entityManager->flush();
$this->repository->saveActivity($activity);
$view = new View($activity, Response::HTTP_OK);
$view->getContext()->setGroups(['Default', 'Entity', 'Activity']);
return $this->viewHandler->handle($view);
}
/**
* Sets the value of a meta-field for an existing activity.
*
* @SWG\Response(
* response=200,
* description="Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.",
* @SWG\Schema(ref="#/definitions/ActivityEntity")
* )
* @SWG\Parameter(
* name="id",
* in="path",
* type="integer",
* description="Activity record ID to set the meta-field value for",
* required=true,
* )
* @Rest\RequestParam(name="name", strict=true, nullable=false, description="The meta-field name")
* @Rest\RequestParam(name="value", strict=true, nullable=false, description="The meta-field value")
*
* @param int $id
* @param ParamFetcherInterface $paramFetcher
* @return Response
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function metaAction($id, ParamFetcherInterface $paramFetcher)
{
$activity = $this->repository->find($id);
if (null === $activity) {
throw new NotFoundException();
}
if (!$this->isGranted('edit', $activity)) {
throw new AccessDeniedHttpException('You are not allowed to update this activity');
}
$event = new ActivityMetaDefinitionEvent($activity);
$this->dispatcher->dispatch($event, ActivityMetaDefinitionEvent::class);
$name = $paramFetcher->get('name');
$value = $paramFetcher->get('value');
if (null === ($meta = $activity->getMetaField($name))) {
throw new \InvalidArgumentException('Unknown meta-field requested');
}
$meta->setValue($value);
$this->repository->saveActivity($activity);
$view = new View($activity, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Project']);
return $this->viewHandler->handle($view);
}
}

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace App\API;
use App\Entity\Customer;
use App\Event\CustomerMetaDefinitionEvent;
use App\Form\API\CustomerApiEditForm;
use App\Repository\CustomerRepository;
use App\Repository\Query\CustomerQuery;
@@ -22,6 +23,7 @@ use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -36,16 +38,21 @@ class CustomerController extends BaseApiController
/**
* @var CustomerRepository
*/
protected $repository;
private $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
private $viewHandler;
/**
* @var EventDispatcherInterface
*/
private $dispatcher;
public function __construct(ViewHandlerInterface $viewHandler, CustomerRepository $repository)
public function __construct(ViewHandlerInterface $viewHandler, CustomerRepository $repository, EventDispatcherInterface $dispatcher)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
$this->dispatcher = $dispatcher;
}
/**
@@ -68,6 +75,7 @@ class CustomerController extends BaseApiController
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$query = new CustomerQuery();
$query->setCurrentUser($this->getUser());
if (null !== ($order = $paramFetcher->get('order'))) {
$query->setOrder($order);
@@ -147,14 +155,15 @@ class CustomerController extends BaseApiController
$customer = new Customer();
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch($event, CustomerMetaDefinitionEvent::class);
$form = $this->createForm(CustomerApiEditForm::class, $customer);
$form->submit($request->request->all());
if ($form->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($customer);
$entityManager->flush();
$this->repository->saveCustomer($customer);
$view = new View($customer, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Customer']);
@@ -196,6 +205,8 @@ class CustomerController extends BaseApiController
* @param Request $request
* @param string $id
* @return Response
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function patchAction(Request $request, string $id)
{
@@ -209,6 +220,9 @@ class CustomerController extends BaseApiController
throw new AccessDeniedHttpException('User cannot update customer');
}
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch($event, CustomerMetaDefinitionEvent::class);
$form = $this->createForm(CustomerApiEditForm::class, $customer);
$form->setData($customer);
@@ -221,13 +235,67 @@ class CustomerController extends BaseApiController
return $this->viewHandler->handle($view);
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($customer);
$entityManager->flush();
$this->repository->saveCustomer($customer);
$view = new View($customer, Response::HTTP_OK);
$view->getContext()->setGroups(['Default', 'Entity', 'Customer']);
return $this->viewHandler->handle($view);
}
/**
* Sets the value of a meta-field for an existing customer.
*
* @SWG\Response(
* response=200,
* description="Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.",
* @SWG\Schema(ref="#/definitions/CustomerEntity")
* )
* @SWG\Parameter(
* name="id",
* in="path",
* type="integer",
* description="Customer record ID to set the meta-field value for",
* required=true,
* )
* @Rest\RequestParam(name="name", strict=true, nullable=false, description="The meta-field name")
* @Rest\RequestParam(name="value", strict=true, nullable=false, description="The meta-field value")
*
* @param int $id
* @param ParamFetcherInterface $paramFetcher
* @return Response
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function metaAction($id, ParamFetcherInterface $paramFetcher)
{
$customer = $this->repository->find($id);
if (null === $customer) {
throw new NotFoundException();
}
if (!$this->isGranted('edit', $customer)) {
throw new AccessDeniedHttpException('You are not allowed to update this customer');
}
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch($event, CustomerMetaDefinitionEvent::class);
$name = $paramFetcher->get('name');
$value = $paramFetcher->get('value');
if (null === ($meta = $customer->getMetaField($name))) {
throw new \InvalidArgumentException('Unknown meta-field requested');
}
$meta->setValue($value);
$this->repository->saveCustomer($customer);
$view = new View($customer, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Customer']);
return $this->viewHandler->handle($view);
}
}

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace App\API;
use App\Entity\Project;
use App\Event\ProjectMetaDefinitionEvent;
use App\Form\API\ProjectApiEditForm;
use App\Repository\ProjectRepository;
use App\Repository\Query\ProjectQuery;
@@ -22,6 +23,7 @@ use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -36,16 +38,21 @@ class ProjectController extends BaseApiController
/**
* @var ProjectRepository
*/
protected $repository;
private $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
private $viewHandler;
/**
* @var EventDispatcherInterface
*/
private $dispatcher;
public function __construct(ViewHandlerInterface $viewHandler, ProjectRepository $repository)
public function __construct(ViewHandlerInterface $viewHandler, ProjectRepository $repository, EventDispatcherInterface $dispatcher)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
$this->dispatcher = $dispatcher;
}
/**
@@ -70,6 +77,7 @@ class ProjectController extends BaseApiController
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$query = new ProjectQuery();
$query->setCurrentUser($this->getUser());
if (null !== ($order = $paramFetcher->get('order'))) {
$query->setOrder($order);
@@ -153,14 +161,15 @@ class ProjectController extends BaseApiController
$project = new Project();
$event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch($event, ProjectMetaDefinitionEvent::class);
$form = $this->createForm(ProjectApiEditForm::class, $project);
$form->submit($request->request->all());
if ($form->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($project);
$entityManager->flush();
$this->repository->saveProject($project);
$view = new View($project, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Project']);
@@ -202,6 +211,8 @@ class ProjectController extends BaseApiController
* @param Request $request
* @param string $id
* @return Response
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function patchAction(Request $request, string $id)
{
@@ -215,6 +226,9 @@ class ProjectController extends BaseApiController
throw new AccessDeniedHttpException('User cannot update project');
}
$event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch($event, ProjectMetaDefinitionEvent::class);
$form = $this->createForm(ProjectApiEditForm::class, $project);
$form->setData($project);
@@ -227,13 +241,67 @@ class ProjectController extends BaseApiController
return $this->viewHandler->handle($view);
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($project);
$entityManager->flush();
$this->repository->saveProject($project);
$view = new View($project, Response::HTTP_OK);
$view->getContext()->setGroups(['Default', 'Entity', 'Project']);
return $this->viewHandler->handle($view);
}
/**
* Sets the value of a meta-field for an existing project.
*
* @SWG\Response(
* response=200,
* description="Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.",
* @SWG\Schema(ref="#/definitions/ProjectEntity")
* )
* @SWG\Parameter(
* name="id",
* in="path",
* type="integer",
* description="Project record ID to set the meta-field value for",
* required=true,
* )
* @Rest\RequestParam(name="name", strict=true, nullable=false, description="The meta-field name")
* @Rest\RequestParam(name="value", strict=true, nullable=false, description="The meta-field value")
*
* @param int $id
* @param ParamFetcherInterface $paramFetcher
* @return Response
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function metaAction($id, ParamFetcherInterface $paramFetcher)
{
$project = $this->repository->find($id);
if (null === $project) {
throw new NotFoundException();
}
if (!$this->isGranted('edit', $project)) {
throw new AccessDeniedHttpException('You are not allowed to update this project');
}
$event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch($event, ProjectMetaDefinitionEvent::class);
$name = $paramFetcher->get('name');
$value = $paramFetcher->get('value');
if (null === ($meta = $project->getMetaField($name))) {
throw new \InvalidArgumentException('Unknown meta-field requested');
}
$meta->setValue($value);
$this->repository->saveProject($project);
$view = new View($project, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Project']);
return $this->viewHandler->handle($view);
}
}

View File

@@ -104,9 +104,7 @@ class TagController extends BaseApiController
throw new NotFoundException();
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->remove($tag);
$entityManager->flush();
$this->repository->deleteTag($tag);
$view = new View(null, Response::HTTP_NO_CONTENT);

133
src/API/TeamController.php Normal file
View File

@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\API;
use App\Entity\Team;
use App\Repository\TeamRepository;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Response;
/**
* @RouteResource("Team")
*/
class TeamController extends BaseApiController
{
/**
* @var TeamRepository
*/
protected $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
public function __construct(ViewHandlerInterface $viewHandler, TeamRepository $repository)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
}
/**
* Fetch all existing teams
*
* @SWG\Response(
* response=200,
* description="Returns the collection of all existing teams",
* @SWG\Schema(
* type="array",
* @SWG\Items(ref="#/definitions/TeamCollection")
* )
* )
*
* @Security("is_granted('view_team')")
*
* @return Response
*/
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$data = $this->repository->findAll();
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection', 'Team']);
return $this->viewHandler->handle($view);
}
/**
* Returns one team
*
* @SWG\Response(
* response=200,
* description="Returns one team entity",
* @SWG\Schema(ref="#/definitions/TeamEntity"),
* )
*
* @param int $id
* @return Response
*/
public function getAction($id)
{
/** @var Team $data */
$data = $this->repository->find($id);
if (null === $data) {
throw new NotFoundException();
}
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Team']);
return $this->viewHandler->handle($view);
}
/**
* Delete a team
*
* @SWG\Delete(
* @SWG\Response(
* response=204,
* description="Delete one team"
* ),
* )
* @SWG\Parameter(
* name="id",
* in="path",
* type="integer",
* description="Team ID to delete",
* required=true,
* )
*
* @Security("is_granted('delete_team')")
*
* @param int $id
* @return Response
*/
public function deleteAction($id)
{
$team = $this->repository->find($id);
if (null === $team) {
throw new NotFoundException();
}
$this->repository->deleteTeam($team);
$view = new View(null, Response::HTTP_NO_CONTENT);
return $this->viewHandler->handle($view);
}
}

View File

@@ -14,6 +14,7 @@ namespace App\API;
use App\Configuration\TimesheetConfiguration;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Event\TimesheetMetaDefinitionEvent;
use App\Form\API\TimesheetApiEditForm;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TagRepository;
@@ -30,6 +31,7 @@ use FOS\RestBundle\View\ViewHandlerInterface;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -47,27 +49,31 @@ class TimesheetController extends BaseApiController
/**
* @var TimesheetRepository
*/
protected $repository;
private $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
private $viewHandler;
/**
* @var TimesheetConfiguration
*/
protected $configuration;
private $configuration;
/**
* @var UserDateTimeFactory
*/
protected $dateTime;
private $dateTime;
/**
* @var TagRepository
*/
protected $tagRepository;
private $tagRepository;
/**
* @var TrackingModeService
*/
protected $trackingModeService;
private $trackingModeService;
/**
* @var EventDispatcherInterface
*/
private $dispatcher;
public function __construct(
ViewHandlerInterface $viewHandler,
@@ -75,7 +81,8 @@ class TimesheetController extends BaseApiController
UserDateTimeFactory $dateTime,
TimesheetConfiguration $configuration,
TagRepository $tagRepository,
TrackingModeService $trackingModeService
TrackingModeService $trackingModeService,
EventDispatcherInterface $dispatcher
) {
$this->viewHandler = $viewHandler;
$this->repository = $repository;
@@ -83,6 +90,7 @@ class TimesheetController extends BaseApiController
$this->dateTime = $dateTime;
$this->tagRepository = $tagRepository;
$this->trackingModeService = $trackingModeService;
$this->dispatcher = $dispatcher;
}
protected function getTrackingMode(): TrackingModeInterface
@@ -282,6 +290,9 @@ class TimesheetController extends BaseApiController
$timesheet->setUser($this->getUser());
$timesheet->setBegin($this->dateTime->createDateTime());
$event = new TimesheetMetaDefinitionEvent($timesheet);
$this->dispatcher->dispatch($event, TimesheetMetaDefinitionEvent::class);
$mode = $this->getTrackingMode();
$form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
@@ -305,9 +316,7 @@ class TimesheetController extends BaseApiController
);
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($timesheet);
$entityManager->flush();
$this->repository->save($timesheet);
$view = new View($timesheet, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
@@ -362,6 +371,9 @@ class TimesheetController extends BaseApiController
throw new AccessDeniedHttpException('You are not allowed to update this timesheet');
}
$event = new TimesheetMetaDefinitionEvent($timesheet);
$this->dispatcher->dispatch($event, TimesheetMetaDefinitionEvent::class);
$mode = $this->getTrackingMode();
$form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
@@ -382,9 +394,7 @@ class TimesheetController extends BaseApiController
return $this->viewHandler->handle($view);
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($timesheet);
$entityManager->flush();
$this->repository->save($timesheet);
$view = new View($timesheet, Response::HTTP_OK);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
@@ -409,8 +419,6 @@ class TimesheetController extends BaseApiController
* required=true,
* )
*
* @Security("is_granted('delete_own_timesheet') or is_granted('delete_other_timesheet')")
*
* @param int $id
* @return Response
*/
@@ -523,8 +531,6 @@ class TimesheetController extends BaseApiController
* required=true,
* )
*
* @Security("is_granted('stop_own_timesheet') or is_granted('stop_other_timesheet')")
*
* @param int $id
* @return Response
* @throws \App\Repository\RepositoryException
@@ -568,9 +574,7 @@ class TimesheetController extends BaseApiController
* required=true,
* )
*
* @Rest\RequestParam(name="copy", requirements="all|tags|description", strict=true, nullable=true, description="Whether description and tags are copied to the new entry. Allowed values: all, tags, description (default: nothing is copied)")
*
* @Security("is_granted('start_own_timesheet') or is_granted('start_other_timesheet')")
* @Rest\RequestParam(name="copy", requirements="all|tags|rates|meta|description", strict=true, nullable=true, description="Whether data should be copied to the new entry. Allowed values: all, tags, rates, description, meta (default: nothing is copied)")
*
* @param int $id
* @return Response
@@ -582,8 +586,6 @@ class TimesheetController extends BaseApiController
{
/** @var Timesheet $timesheet */
$timesheet = $this->repository->find($id);
/** @var User $user */
$user = $this->getUser();
if (null === $timesheet) {
throw new NotFoundException();
@@ -593,8 +595,11 @@ class TimesheetController extends BaseApiController
throw new AccessDeniedHttpException('You are not allowed to re-start this timesheet');
}
$entry = new Timesheet();
$entry
/** @var User $user */
$user = $this->getUser();
$copyTimesheet = new Timesheet();
$copyTimesheet
->setBegin($this->dateTime->createDateTime())
->setUser($user)
->setActivity($timesheet->getActivity())
@@ -602,18 +607,30 @@ class TimesheetController extends BaseApiController
;
if (null !== ($copy = $paramFetcher->get('copy'))) {
if (in_array($copy, ['rates', 'all'])) {
$copyTimesheet->setHourlyRate($timesheet->getHourlyRate());
$copyTimesheet->setFixedRate($timesheet->getFixedRate());
}
if (in_array($copy, ['description', 'all'])) {
$entry->setDescription($timesheet->getDescription());
$copyTimesheet->setDescription($timesheet->getDescription());
}
if (in_array($copy, ['tags', 'all'])) {
foreach ($timesheet->getTags() as $tag) {
$entry->addTag($tag);
$copyTimesheet->addTag($tag);
}
}
if (in_array($copy, ['meta', 'all'])) {
foreach ($timesheet->getMetaFields() as $metaField) {
$metaNew = clone $metaField;
$copyTimesheet->setMetaField($metaNew);
}
}
}
$errors = $validator->validate($entry);
$errors = $validator->validate($copyTimesheet);
if (count($errors) > 0) {
throw new BadRequestHttpException($errors[0]->getPropertyPath() . ' = ' . $errors[0]->getMessage());
@@ -624,11 +641,9 @@ class TimesheetController extends BaseApiController
$this->configuration->getActiveEntriesHardLimit()
);
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($entry);
$entityManager->flush();
$this->repository->save($copyTimesheet);
$view = new View($entry, 200);
$view = new View($copyTimesheet, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
return $this->viewHandler->handle($view);
@@ -650,10 +665,10 @@ class TimesheetController extends BaseApiController
* required=true,
* )
*
* @Security("is_granted('edit_export_own_timesheet') or is_granted('edit_export_other_timesheet')")
*
* @param int $id
* @return Response
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function exportAction($id)
{
@@ -672,9 +687,63 @@ class TimesheetController extends BaseApiController
$timesheet->setExported(!$timesheet->isExported());
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($timesheet);
$entityManager->flush();
$this->repository->save($timesheet);
$view = new View($timesheet, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
return $this->viewHandler->handle($view);
}
/**
* Sets the value of a meta-field for an existing timesheet.
*
* @SWG\Response(
* response=200,
* description="Sets the value of an existing/configured meta-field. You cannot create unknown meta-fields, if the given name is not a configured meta-field, this will return an exception.",
* @SWG\Schema(ref="#/definitions/TimesheetEntity")
* )
* @SWG\Parameter(
* name="id",
* in="path",
* type="integer",
* description="Timesheet record ID to set the meta-field value for",
* required=true,
* )
* @Rest\RequestParam(name="name", strict=true, nullable=false, description="The meta-field name")
* @Rest\RequestParam(name="value", strict=true, nullable=false, description="The meta-field value")
*
* @param int $id
* @param ParamFetcherInterface $paramFetcher
* @return Response
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function metaAction($id, ParamFetcherInterface $paramFetcher)
{
$timesheet = $this->repository->find($id);
if (null === $timesheet) {
throw new NotFoundException();
}
if (!$this->isGranted('edit', $timesheet)) {
throw new AccessDeniedHttpException('You are not allowed to update this timesheet');
}
$event = new TimesheetMetaDefinitionEvent($timesheet);
$this->dispatcher->dispatch($event, TimesheetMetaDefinitionEvent::class);
$name = $paramFetcher->get('name');
$value = $paramFetcher->get('value');
if (null === ($meta = $timesheet->getMetaField($name))) {
throw new \InvalidArgumentException('Unknown meta-field requested');
}
$meta->setValue($value);
$this->repository->save($timesheet);
$view = new View($timesheet, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);

View File

@@ -1,151 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Command;
use App\Entity\Timesheet;
use App\Repository\TimesheetRepository;
use Doctrine\DBAL\Types\DateTimeType;
use Doctrine\DBAL\Types\Type;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* This command was added with v0.8.
* Kimai saved the DateTime before with the local timezone, which can cause big problems when used in different environments.
* You should convert all timesheet records that were saved with Kimai 2 directly, but NOT the ones migrated from Kimai v1.
*
* Please read https://github.com/kevinpapst/kimai2/pull/372 to find out more!
*
* @codeCoverageIgnore
*/
class ConvertTimezoneCommand extends Command
{
/**
* @var TimesheetRepository
*/
protected $repository;
/**
* @param TimesheetRepository $repository
*/
public function __construct(TimesheetRepository $repository)
{
$this->repository = $repository;
parent::__construct();
}
/**
* {@inheritdoc}
*/
protected function configure()
{
$this
->setName('kimai:convert-timezone')
->setDescription('Convert timesheet dates between timezones, only made for updates from 0.7 to 0.8')
->setHelp('This command should only be required if you imported data from Kimai v1')
->addOption('first-id', 'f', InputArgument::OPTIONAL, 'The database ID which should be converted first')
->addOption('last-id', 'l', InputArgument::OPTIONAL, 'The database ID which should be converted last')
;
}
/**
* @param null|string $start
* @param null|string $end
* @return Timesheet[]
*/
protected function getTimesheets($start = null, $end = null)
{
$qb = $this->repository->createQueryBuilder('t');
$qb->select('t');
if (!empty($start)) {
$qb->andWhere($qb->expr()->gte('t.id', $start));
}
if (!empty($end)) {
$qb->andWhere($qb->expr()->lte('t.id', $end));
}
return $qb->getQuery()->getResult();
}
/**
* @param Timesheet $timesheet
* @param \DateTimeZone $timeZone
* @return Timesheet
*/
protected function convertTimesheet(Timesheet $timesheet, \DateTimeZone $timeZone): Timesheet
{
$oldTimezone = $timesheet->getTimezone();
if (null !== $timesheet->getBegin()) {
$beginDate = clone $timesheet->getBegin();
$beginDate->setTimezone($timeZone);
$timesheet->setBegin($beginDate);
}
if (null !== $timesheet->getEnd()) {
$endDate = clone $timesheet->getEnd();
$endDate->setTimezone($timeZone);
$timesheet->setEnd($endDate);
}
$timesheet->setTimezone($oldTimezone);
return $timesheet;
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
Type::overrideType(Type::DATETIME, DateTimeType::class);
$io = new SymfonyStyle($input, $output);
$start = $input->getOption('first-id');
$end = $input->getOption('last-id');
$result = $this->getTimesheets($start, $end);
$amount = count($result);
$answer = $io->ask(sprintf('This will update %s timesheet records, continue (y/n) ? ', $amount), 'y');
if ('y' !== $answer) {
$io->text('Aborting.');
return 1;
}
$utc = new \DateTimeZone('UTC');
$i = 0;
/** @var Timesheet $timesheet */
foreach ($result as $timesheet) {
$timesheet = $this->convertTimesheet($timesheet, $utc);
$this->repository->save($timesheet);
if (++$i % 80 === 0) {
$io->writeln('. (' . $i . '/' . $amount . ')');
} else {
$io->write('.');
}
}
$io->writeln('. (' . $i . '/' . $amount . ')');
$io->writeln('');
return 0;
}
}

View File

@@ -51,6 +51,14 @@ class CreateReleaseCommand extends Command
->addOption('directory', null, InputOption::VALUE_OPTIONAL, 'Directory where the release package will be stored', 'var/data/')
->addOption('release', null, InputOption::VALUE_OPTIONAL, 'The version that should be zipped', Constants::VERSION)
;
/*
* Hide this command in production.
* Maybe it should be de-activated completely?!
*/
if (getenv('APP_ENV') === 'prod') {
$this->setHidden(true);
}
}
/**
@@ -106,17 +114,14 @@ class CreateReleaseCommand extends Command
$tar .= '.tar.gz';
$zip .= '.zip';
// this removes the current env settings, as they might differ from the release ones
// if we don't unset them, the .env file won't be read when executing bin/console commands
putenv('DATABASE_URL');
putenv('APP_ENV');
$prefix = 'APP_ENV=prod DATABASE_URL=sqlite:///%kernel.project_dir%/var/data/kimai.sqlite';
$commands = [
'Clone repository' => $gitCmd . ' ' . $tmpDir,
'Install composer dependencies' => 'cd ' . $tmpDir . ' && composer install --no-dev --optimize-autoloader',
'Create database' => 'cd ' . $tmpDir . ' && bin/console doctrine:database:create -n',
'Create tables' => 'cd ' . $tmpDir . ' && bin/console doctrine:schema:create -n',
'Add all migrations' => 'cd ' . $tmpDir . ' && bin/console doctrine:migrations:version --add --all -n',
'Create database' => 'cd ' . $tmpDir . ' && ' . $prefix . ' bin/console doctrine:database:create -n',
'Create tables' => 'cd ' . $tmpDir . ' && ' . $prefix . ' bin/console doctrine:schema:create -n',
'Add all migrations' => 'cd ' . $tmpDir . ' && ' . $prefix . ' bin/console doctrine:migrations:version --add --all -n',
];
$filesToDelete = [

View File

@@ -106,9 +106,6 @@ class CreateUserCommand extends Command
->setRoles(explode(',', $role))
;
$pwd = $this->encoder->encodePassword($user, $user->getPlainPassword());
$user->setPassword($pwd);
$errors = $this->validator->validate($user, null, ['Registration']);
if ($errors->count() > 0) {
/** @var \Symfony\Component\Validator\ConstraintViolation $error */
@@ -126,6 +123,9 @@ class CreateUserCommand extends Command
}
try {
$pwd = $this->encoder->encodePassword($user, $user->getPlainPassword());
$user->setPassword($pwd);
$entityManager = $this->doctrine->getManager();
$entityManager->persist($user);
$entityManager->flush();

View File

@@ -514,6 +514,10 @@ class KimaiImporterCommand extends Command
continue;
}
if (empty($pref['value'])) {
continue;
}
$newPref = new UserPreference();
$newPref
->setName($prefsToImport[$key])
@@ -830,9 +834,9 @@ class KimaiImporterCommand extends Command
$activity = new Activity();
$activity
->setName($name)
->setComment($oldActivity['comment'] ?: null)
->setComment($oldActivity['comment'] ?? null)
->setVisible($isActive)
->setBudget($oldActivity['budget'] ?: 0)
->setBudget($oldActivity['budget'] ?? 0)
;
if (null !== $projectId) {
@@ -1059,7 +1063,7 @@ class KimaiImporterCommand extends Command
// ---------------------------------------------------------------------
$timesheet
->setDescription($oldRecord['description'] ?: ($oldRecord['comment'] ?: null))
->setDescription($oldRecord['description'] ?? ($oldRecord['comment'] ?? null))
->setUser($this->users[$oldRecord['userID']])
->setBegin($begin)
->setEnd($end)

View File

@@ -0,0 +1,75 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Command;
use App\Plugin\PluginManager;
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 fetch plugin information.
*/
class PluginCommand extends Command
{
/**
* @var PluginManager
*/
private $plugins;
public function __construct(PluginManager $plugins)
{
$this->plugins = $plugins;
parent::__construct();
}
/**
* {@inheritdoc}
*/
protected function configure()
{
$this
->setName('kimai:plugins')
->setDescription('Receive plugin information')
->setHelp('This command prints detailed plugin information.')
;
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$plugins = $this->plugins->getPlugins();
if (empty($plugins)) {
$io->warning('No plugins installed');
return 0;
}
$rows = [];
foreach ($plugins as $plugin) {
$this->plugins->loadMetadata($plugin);
$meta = $plugin->getMetadata();
$rows[] = [
$plugin->getName(),
$meta->getVersion(),
$meta->getKimaiVersion(),
$plugin->getPath(),
];
}
$io->table(['Name', 'Version', 'Requires', 'Directory'], $rows);
return 0;
}
}

View File

@@ -9,6 +9,7 @@
namespace App\Command;
use Exception;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArrayInput;
@@ -46,6 +47,17 @@ EOT
;
}
/**
* Make sure that this command CANNOT be executed in production.
* It can't work, as the fixtures bundle is not available in production.
*
* @return bool
*/
public function isEnabled()
{
return getenv('APP_ENV') !== 'prod';
}
/**
* @param InputInterface $input
* @param OutputInterface $output
@@ -55,17 +67,11 @@ EOT
{
$io = new SymfonyStyle($input, $output);
if (getenv('APP_ENV') === 'prod') {
$io->error('kimai:reset-dev is not allowed in production');
return -1;
}
if ($this->askConfirmation($input, $output, 'Do you want to create the database y/N ?')) {
try {
$command = $this->getApplication()->find('doctrine:database:create');
$command->run(new ArrayInput([]), $output);
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to create database: ' . $ex->getMessage());
return 1;
@@ -76,7 +82,7 @@ EOT
try {
$command = $this->getApplication()->find('doctrine:schema:drop');
$command->run(new ArrayInput(['--force' => true]), $output);
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to drop database schema: ' . $ex->getMessage());
return 2;
@@ -85,7 +91,7 @@ EOT
try {
$command = $this->getApplication()->find('doctrine:schema:create');
$command->run(new ArrayInput([]), $output);
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to create database schema: ' . $ex->getMessage());
return 3;
@@ -97,7 +103,7 @@ EOT
$cmdInput = new ArrayInput([]);
$cmdInput->setInteractive(false);
$command->run($cmdInput, $output);
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to import fixtures: ' . $ex->getMessage());
return 4;
@@ -108,7 +114,7 @@ EOT
$cmdInput = new ArrayInput(['--add' => true, '--all' => true]);
$cmdInput->setInteractive(false);
$command->run($cmdInput, $output);
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to set migration status: ' . $ex->getMessage());
return 5;
@@ -118,7 +124,7 @@ EOT
$command = $this->getApplication()->find('cache:clear');
try {
$command->run(new ArrayInput([]), $output);
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to clear cache: ' . $ex->getMessage());
return 6;

View File

@@ -97,4 +97,9 @@ class CalendarConfiguration implements SystemBundleConfiguration
{
return $this->find('google.sources');
}
public function getSlotDuration(): string
{
return (string) $this->find('slot_duration');
}
}

View File

@@ -47,4 +47,9 @@ class FormConfiguration implements SystemBundleConfiguration
{
return $this->find('user.language');
}
public function getUserDefaultCurrency(): string
{
return $this->find('user.currency');
}
}

View File

@@ -21,7 +21,7 @@ class Constants
/**
* The current release version
*/
public const VERSION = '1.1';
public const VERSION = '1.2';
/**
* The release name, will only change for new major version
*/

View File

@@ -9,6 +9,7 @@
namespace App\Controller;
use App\Configuration\FormConfiguration;
use App\Entity\Activity;
use App\Entity\Project;
use App\Event\ActivityMetaDefinitionEvent;
@@ -40,14 +41,19 @@ class ActivityController extends AbstractController
* @var ActivityRepository
*/
private $repository;
/**
* @var FormConfiguration
*/
private $configuration;
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
public function __construct(ActivityRepository $repository, EventDispatcherInterface $dispatcher)
public function __construct(ActivityRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher)
{
$this->repository = $repository;
$this->configuration = $configuration;
$this->dispatcher = $dispatcher;
}
@@ -68,6 +74,7 @@ class ActivityController extends AbstractController
public function indexAction($page, Request $request)
{
$query = new ActivityQuery();
$query->setCurrentUser($this->getUser());
$query->setPage($page);
$form = $this->getToolbarForm($query);
@@ -197,7 +204,7 @@ class ActivityController extends AbstractController
protected function renderActivityForm(Activity $activity, Request $request)
{
$event = new ActivityMetaDefinitionEvent($activity);
$this->dispatcher->dispatch(ActivityMetaDefinitionEvent::class, $event);
$this->dispatcher->dispatch($event, ActivityMetaDefinitionEvent::class);
$editForm = $this->createEditForm($activity);
$editForm->handleRequest($request);
@@ -250,15 +257,20 @@ class ActivityController extends AbstractController
*/
private function createEditForm(Activity $activity)
{
if ($activity->getId() === null) {
$url = $this->generateUrl('admin_activity_create');
} else {
$currency = $this->configuration->getCustomerDefaultCurrency();
$url = $this->generateUrl('admin_activity_create');
if ($activity->getId() !== null) {
$url = $this->generateUrl('admin_activity_edit', ['id' => $activity->getId()]);
if (null !== $activity->getProject()) {
$currency = $activity->getProject()->getCustomer()->getCurrency();
}
}
return $this->createForm(ActivityEditForm::class, $activity, [
'action' => $url,
'method' => 'POST',
'currency' => $currency,
'create_more' => true,
'customer' => true,
'include_budget' => $this->isGranted('budget', $activity)

View File

@@ -13,6 +13,7 @@ use App\Configuration\FormConfiguration;
use App\Entity\Customer;
use App\Event\CustomerMetaDefinitionEvent;
use App\Form\CustomerEditForm;
use App\Form\CustomerTeamPermissionForm;
use App\Form\Toolbar\CustomerToolbarForm;
use App\Form\Type\CustomerType;
use App\Repository\CustomerRepository;
@@ -71,14 +72,11 @@ class CustomerController extends AbstractController
* @Route(path="/", defaults={"page": 1}, name="admin_customer", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_customer_paginated", methods={"GET"})
* @Security("is_granted('view_customer')")
*
* @param int $page
* @param Request $request
* @return Response
*/
public function indexAction($page, Request $request)
{
$query = new CustomerQuery();
$query->setCurrentUser($this->getUser());
$query->setPage($page);
$form = $this->getToolbarForm($query);
@@ -98,9 +96,6 @@ class CustomerController extends AbstractController
/**
* @Route(path="/create", name="admin_customer_create", methods={"GET", "POST"})
* @Security("is_granted('create_customer')")
*
* @param Request $request
* @return RedirectResponse|Response
*/
public function createAction(Request $request)
{
@@ -117,12 +112,39 @@ class CustomerController extends AbstractController
return $this->renderCustomerForm($customer, $request);
}
/**
* @Route(path="/{id}/permissions", name="admin_customer_permissions", methods={"GET", "POST"})
* @Security("is_granted('permissions', customer)")
*/
public function teamPermissions(Customer $customer, Request $request)
{
$form = $this->createForm(CustomerTeamPermissionForm::class, $customer, [
'action' => $this->generateUrl('admin_customer_permissions', ['id' => $customer->getId()]),
'method' => 'POST',
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->getRepository()->saveCustomer($customer);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_customer');
} catch (ORMException $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->render('customer/permissions.html.twig', [
'customer' => $customer,
'form' => $form->createView()
]);
}
/**
* @Route(path="/{id}/budget", name="admin_customer_budget", methods={"GET"})
* @Security("is_granted('budget', customer)")
*
* @param Customer $customer
* @return Response
*/
public function budgetAction(Customer $customer)
{
@@ -135,10 +157,6 @@ class CustomerController extends AbstractController
/**
* @Route(path="/{id}/edit", name="admin_customer_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', customer)")
*
* @param Customer $customer
* @param Request $request
* @return RedirectResponse|Response
*/
public function editAction(Customer $customer, Request $request)
{
@@ -148,10 +166,6 @@ class CustomerController extends AbstractController
/**
* @Route(path="/{id}/delete", name="admin_customer_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', customer)")
*
* @param Customer $customer
* @param Request $request
* @return RedirectResponse|Response
*/
public function deleteAction(Customer $customer, Request $request)
{
@@ -169,6 +183,7 @@ class CustomerController extends AbstractController
'query_builder' => function (CustomerRepository $repo) use ($customer) {
$query = new CustomerFormTypeQuery();
$query->setCustomerToIgnore($customer);
$query->setUser($this->getUser());
return $repo->getQueryBuilderForFormType($query);
},
@@ -206,7 +221,7 @@ class CustomerController extends AbstractController
protected function renderCustomerForm(Customer $customer, Request $request)
{
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch(CustomerMetaDefinitionEvent::class, $event);
$this->dispatcher->dispatch($event, CustomerMetaDefinitionEvent::class);
$editForm = $this->createEditForm($customer);
@@ -229,11 +244,7 @@ class CustomerController extends AbstractController
]);
}
/**
* @param CustomerQuery $query
* @return FormInterface
*/
protected function getToolbarForm(CustomerQuery $query)
protected function getToolbarForm(CustomerQuery $query): FormInterface
{
return $this->createForm(CustomerToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_customer', [
@@ -243,11 +254,7 @@ class CustomerController extends AbstractController
]);
}
/**
* @param Customer $customer
* @return FormInterface
*/
private function createEditForm(Customer $customer)
private function createEditForm(Customer $customer): FormInterface
{
if ($customer->getId() === null) {
$url = $this->generateUrl('admin_customer_create');

View File

@@ -88,10 +88,7 @@ class DashboardController extends AbstractController
$event->addSection($row);
}
$this->eventDispatcher->dispatch(
DashboardEvent::DASHBOARD,
$event
);
$this->eventDispatcher->dispatch($event, DashboardEvent::DASHBOARD);
$sections = $event->getSections();

View File

@@ -17,6 +17,7 @@ use App\Repository\TimesheetRepository;
use App\Timesheet\UserDateTimeFactory;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
@@ -25,7 +26,7 @@ use Symfony\Component\Routing\Annotation\Route;
* Controller used to export timesheet data.
*
* @Route(path="/export")
* @Security("is_granted('view_export')")
* @Security("is_granted('create_export')")
*/
class ExportController extends AbstractController
{
@@ -54,60 +55,45 @@ class ExportController extends AbstractController
$this->dateFactory = $dateTime;
}
/**
* @return ExportQuery
* @throws \Exception
*/
protected function getDefaultQuery()
{
$begin = $this->dateFactory->createDateTime('first day of this month 00:00:00');
$end = $this->dateFactory->createDateTime('last day of this month 23:59:59');
$query = new ExportQuery();
$query->setOrder(ExportQuery::ORDER_ASC);
$query->setBegin($begin);
$query->setEnd($end);
$query->setState(ExportQuery::STATE_STOPPED);
$query->setExported(ExportQuery::STATE_NOT_EXPORTED);
return $query;
}
/**
* @Route(path="/", name="export", methods={"GET"})
* @Security("is_granted('view_export')")
*
* @param Request $request
* @return Response
* @throws \Exception
*/
public function indexAction(Request $request)
public function indexAction(Request $request): Response
{
$query = $this->getDefaultQuery();
$showPreview = false;
$maxItemsPreview = 500;
$entries = [];
$form = $this->getToolbarForm($query, 'GET');
$form->setData($query);
$form->submit($request->query->all(), false);
$entries = $this->getEntries($query);
if ($form->isValid()) {
/** @var SubmitButton $previewButton */
$previewButton = $form->get('preview');
if ($previewButton->isClicked()) {
$showPreview = true;
$query->setPageSize($maxItemsPreview);
$entries = $this->getEntries($query);
}
}
return $this->render('export/index.html.twig', [
'query' => $query,
'entries' => $entries,
'form' => $form->createView(),
'renderer' => $this->export->getRenderer(),
'preview_max' => $maxItemsPreview,
'preview_show' => $showPreview,
]);
}
/**
* @Route(path="/data", name="export_data", methods={"POST"})
* @Security("is_granted('create_export')")
*
* @param Request $request
* @return Response
* @throws \Exception
*/
public function export(Request $request)
public function export(Request $request): Response
{
$query = $this->getDefaultQuery();
@@ -126,8 +112,29 @@ class ExportController extends AbstractController
}
$entries = $this->getEntries($query);
$response = $renderer->render($entries, $query);
return $renderer->render($entries, $query);
if ($query->isMarkAsExported()) {
$this->timesheetRepository->setExported($entries);
}
return $response;
}
protected function getDefaultQuery(): ExportQuery
{
$begin = $this->dateFactory->createDateTime('first day of this month 00:00:00');
$end = $this->dateFactory->createDateTime('last day of this month 23:59:59');
$query = new ExportQuery();
$query->setOrder(ExportQuery::ORDER_ASC);
$query->setBegin($begin);
$query->setEnd($end);
$query->setState(ExportQuery::STATE_STOPPED);
$query->setExported(ExportQuery::STATE_NOT_EXPORTED);
$query->setCurrentUser($this->getUser());
return $query;
}
/**

View File

@@ -13,6 +13,7 @@ use App\Entity\User;
use App\Form\Type\InitialViewType;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
@@ -25,21 +26,41 @@ class HomepageController extends AbstractController
{
/**
* @Route(path="", defaults={}, name="homepage", methods={"GET"})
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse
*/
public function indexAction(Request $request)
public function indexAction(Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
$route = $user->getPreferenceValue('login.initial_view', InitialViewType::DEFAULT_VIEW);
$userRoute = $user->getPreferenceValue('login.initial_view', InitialViewType::DEFAULT_VIEW);
$userLanguage = $user->getLocale();
$requestLanguage = $request->getLocale();
/** @var User $user */
$user = $this->getUser();
$locale = $request->getLocale();
$language = $user->getPreferenceValue('language', $locale);
if (empty($requestLanguage)) {
$requestLanguage = User::DEFAULT_LANGUAGE;
}
return $this->redirectToRoute($route, ['_locale' => $language]);
if (empty($userLanguage)) {
$userLanguage = $requestLanguage;
}
$routes = [
[$userRoute, $userLanguage],
[$userRoute, $requestLanguage],
[$userRoute, User::DEFAULT_LANGUAGE],
[InitialViewType::DEFAULT_VIEW, $userLanguage],
[InitialViewType::DEFAULT_VIEW, $requestLanguage],
];
foreach ($routes as $routeSettings) {
$route = $routeSettings[0];
$language = $routeSettings[1];
try {
return $this->redirectToRoute($route, ['_locale' => $language]);
} catch (\Exception $exception) {
// something is wrong with the url parameters ...
}
}
return $this->redirectToRoute(InitialViewType::DEFAULT_VIEW, ['_locale' => User::DEFAULT_LANGUAGE]);
}
}

View File

@@ -13,8 +13,8 @@ use App\Entity\InvoiceTemplate;
use App\Entity\Timesheet;
use App\Form\InvoiceTemplateForm;
use App\Form\Toolbar\InvoiceToolbarForm;
use App\Invoice\InvoiceModel;
use App\Invoice\ServiceInvoice;
use App\Model\InvoiceModel;
use App\Repository\InvoiceTemplateRepository;
use App\Repository\Query\BaseQuery;
use App\Repository\Query\InvoiceQuery;
@@ -22,14 +22,16 @@ use App\Repository\TimesheetRepository;
use App\Timesheet\UserDateTimeFactory;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used to manage invoices.
* Controller used to create invoices and manage invoice templates.
*
* @Route(path="/invoice")
* @Security("is_granted('view_invoice') or is_granted('view_invoice_template')")
* @Security("is_granted('view_invoice')")
*/
class InvoiceController extends AbstractController
{
@@ -58,25 +60,7 @@ class InvoiceController extends AbstractController
}
/**
* @return InvoiceQuery
* @throws \Exception
*/
protected function getDefaultQuery()
{
$begin = $this->dateTimeFactory->createDateTime('first day of this month');
$end = $this->dateTimeFactory->createDateTime('last day of this month');
$query = new InvoiceQuery();
$query->setOrder(InvoiceQuery::ORDER_ASC);
$query->setBegin($begin);
$query->setEnd($end);
$query->setState(InvoiceQuery::STATE_STOPPED);
return $query;
}
/**
* @Route(path="/", name="invoice", methods={"GET"})
* @Route(path="/", name="invoice", methods={"GET", "POST"})
* @Security("is_granted('view_invoice')")
*
* @param Request $request
@@ -86,9 +70,14 @@ class InvoiceController extends AbstractController
public function indexAction(Request $request, TimesheetRepository $repository)
{
if (!$this->invoiceRepository->hasTemplate()) {
return $this->redirectToRoute('admin_invoice_template_create');
if ($this->isGranted('manage_invoice_template')) {
return $this->redirectToRoute('admin_invoice_template_create');
}
$this->flashWarning('invoice.first_template');
}
$showPreview = false;
$maxItemsPreview = 500;
$entries = [];
$query = $this->getDefaultQuery();
@@ -96,8 +85,22 @@ class InvoiceController extends AbstractController
$form->setData($query);
$form->submit($request->query->all(), false);
if ($form->isValid()) {
$entries = $this->getEntries($query, $repository);
if ($this->isGranted('create_invoice')) {
if ($form->isValid()) {
/** @var SubmitButton $createButton */
$createButton = $form->get('create');
if ($createButton->isClicked()) {
return $this->renderInvoice($query, $repository);
}
/** @var SubmitButton $previewButton */
$previewButton = $form->get('preview');
if ($previewButton->isClicked()) {
$showPreview = true;
$query->setPageSize($maxItemsPreview);
$entries = $this->getEntries($query, $repository);
}
}
}
$model = $this->prepareModel($query, $entries);
@@ -105,34 +108,29 @@ class InvoiceController extends AbstractController
return $this->render('invoice/index.html.twig', [
'model' => $model,
'form' => $form->createView(),
'preview_max' => $maxItemsPreview,
'preview_show' => $showPreview,
]);
}
/**
* @Route(path="/print", name="invoice_print", methods={"POST"})
* @Security("is_granted('create_invoice')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
*/
public function printAction(Request $request, TimesheetRepository $repository)
protected function getDefaultQuery(): InvoiceQuery
{
if (!$this->invoiceRepository->hasTemplate()) {
return $this->redirectToRoute('admin_invoice_template_create');
}
$begin = $this->dateTimeFactory->createDateTime('first day of this month');
$end = $this->dateTimeFactory->createDateTime('last day of this month');
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query, 'POST');
$query = new InvoiceQuery();
$query->setOrder(InvoiceQuery::ORDER_ASC);
$query->setBegin($begin);
$query->setEnd($end);
$query->setExported(InvoiceQuery::STATE_NOT_EXPORTED);
$query->setState(InvoiceQuery::STATE_STOPPED);
$query->setCurrentUser($this->getUser());
$form->handleRequest($request);
return $query;
}
if (!$form->isSubmitted() || !$form->isValid()) {
return $this->redirectToRoute('invoice');
}
/** @var InvoiceQuery $query */
$query = $form->getData();
protected function renderInvoice(InvoiceQuery $query, TimesheetRepository $repository)
{
$entries = $this->getEntries($query, $repository);
$model = $this->prepareModel($query, $entries);
@@ -143,23 +141,28 @@ class InvoiceController extends AbstractController
foreach ($this->service->getRenderer() as $renderer) {
if ($renderer->supports($document)) {
return $renderer->render($document, $model);
$response = $renderer->render($document, $model);
if ($query->isMarkAsExported()) {
$repository->setExported($entries);
}
return $response;
}
}
$this->flashError('Cannot render invoice: ' . $model->getTemplate()->getRenderer() . ' (' . $document->getName() . ')');
$this->flashError(
sprintf('Cannot render invoice: %s (%s)', $model->getTemplate()->getRenderer(), $document->getName())
);
return $this->render('invoice/index.html.twig', [
'model' => $model,
'form' => $form->createView(),
]);
return $this->redirectToRoute('invoice');
}
/**
* @param InvoiceQuery $query
* @param TimesheetRepository $repository
* @return Timesheet[]
*/
protected function getEntries(InvoiceQuery $query, TimesheetRepository $repository)
protected function getEntries(InvoiceQuery $query, TimesheetRepository $repository): iterable
{
// customer needs to be defined, as we need the currency for the invoice
if (null === $query->getCustomer()) {
@@ -180,11 +183,11 @@ class InvoiceController extends AbstractController
/**
* @param InvoiceQuery $query
* @param array $entries
* @param Timesheet[] $entries
* @return InvoiceModel
* @throws \Exception
*/
protected function prepareModel(InvoiceQuery $query, array $entries)
protected function prepareModel(InvoiceQuery $query, array $entries): InvoiceModel
{
$model = new InvoiceModel();
$model
@@ -213,33 +216,23 @@ class InvoiceController extends AbstractController
}
/**
* @Route(path="/template", defaults={"page": 1}, name="admin_invoice_template", methods={"GET", "POST"})
* @Route(path="/template/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_invoice_template_paginated", methods={"GET", "POST"})
* @Security("is_granted('view_invoice_template')")
*
* @param int $page
* @return \Symfony\Component\HttpFoundation\Response
* @Route(path="/template", name="admin_invoice_template", methods={"GET", "POST"})
* @Security("is_granted('manage_invoice_template')")
*/
public function listTemplateAction($page)
public function listTemplateAction(): Response
{
$templates = $this->invoiceRepository->findByQuery(new BaseQuery());
return $this->render('invoice/templates.html.twig', [
'entries' => $templates,
'page' => $page,
]);
}
/**
* @Route(path="/template/{id}/edit", name="admin_invoice_template_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', template)")
*
* @param InvoiceTemplate $template
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
* @Security("is_granted('manage_invoice_template')")
*/
public function editTemplateAction(InvoiceTemplate $template, Request $request)
public function editTemplateAction(InvoiceTemplate $template, Request $request): Response
{
return $this->renderTemplateForm($template, $request);
}
@@ -247,14 +240,9 @@ class InvoiceController extends AbstractController
/**
* @Route(path="/template/create", name="admin_invoice_template_create", methods={"GET", "POST"})
* @Route(path="/template/create/{id}", name="admin_invoice_template_copy", methods={"GET", "POST"})
* @Security("is_granted('create_invoice_template')")
*
* @param Request $request
* @param InvoiceTemplate|null $copyFrom
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
* @Security("is_granted('manage_invoice_template')")
*/
public function createTemplateAction(Request $request, ?InvoiceTemplate $copyFrom)
public function createTemplateAction(Request $request, ?InvoiceTemplate $copyFrom): Response
{
if (!$this->invoiceRepository->hasTemplate()) {
$this->flashWarning('invoice.first_template');
@@ -280,16 +268,10 @@ class InvoiceController extends AbstractController
}
/**
* The route to delete an existing template.
*
* @Route(path="/template/{id}/delete", name="admin_invoice_template_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', template)")
*
* @param InvoiceTemplate $template
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
* @Security("is_granted('manage_invoice_template')")
*/
public function deleteTemplate(InvoiceTemplate $template, Request $request)
public function deleteTemplate(InvoiceTemplate $template, Request $request): Response
{
try {
$this->invoiceRepository->removeTemplate($template);
@@ -298,15 +280,10 @@ class InvoiceController extends AbstractController
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
}
return $this->redirectToRoute('admin_invoice_template_paginated', ['page' => $request->get('page')]);
return $this->redirectToRoute('admin_invoice_template');
}
/**
* @param InvoiceTemplate $template
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
protected function renderTemplateForm(InvoiceTemplate $template, Request $request)
protected function renderTemplateForm(InvoiceTemplate $template, Request $request): Response
{
$editForm = $this->createEditForm($template);
@@ -340,11 +317,7 @@ class InvoiceController extends AbstractController
]);
}
/**
* @param InvoiceTemplate $template
* @return \Symfony\Component\Form\FormInterface
*/
private function createEditForm(InvoiceTemplate $template)
private function createEditForm(InvoiceTemplate $template): FormInterface
{
if ($template->getId() === null) {
$url = $this->generateUrl('admin_invoice_template_create');

View File

@@ -16,12 +16,14 @@ use App\Form\UserEditType;
use App\Form\UserPasswordType;
use App\Form\UserPreferencesForm;
use App\Form\UserRolesType;
use App\Form\UserTeamsType;
use App\Repository\TimesheetRepository;
use App\Voter\UserVoter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
@@ -175,6 +177,28 @@ class ProfileController extends AbstractController
return $this->getProfileView($profile, 'roles', null, null, $form);
}
/**
* @Route(path="/{username}/teams", name="user_profile_teams", methods={"GET", "POST"})
* @Security("is_granted('teams', profile)")
*/
public function teamsAction(User $profile, Request $request)
{
$form = $this->createTeamsForm($profile);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($profile);
$entityManager->flush();
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile_teams', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, 'teams', null, null, null, null, $form);
}
/**
* @Route(path="/{username}/prefs", name="user_profile_preferences", methods={"GET", "POST"})
* @Security("is_granted('preferences', profile)")
@@ -183,7 +207,7 @@ class ProfileController extends AbstractController
{
// we need to prepare the user preferences, which is done via an EventSubscriber
$event = new PrepareUserEvent($profile);
$this->dispatcher->dispatch(PrepareUserEvent::PREPARE, $event);
$this->dispatcher->dispatch($event, PrepareUserEvent::PREPARE);
$original = [];
foreach ($profile->getPreferences() as $preference) {
@@ -236,24 +260,15 @@ class ProfileController extends AbstractController
]);
}
/**
* @param User $user
* @param string $tab
* @param Form|null $editForm
* @param Form|null $pwdForm
* @param Form|null $rolesForm
* @param Form|null $apiTokenForm
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Doctrine\ORM\NonUniqueResultException
*/
protected function getProfileView(
User $user,
string $tab,
Form $editForm = null,
Form $pwdForm = null,
Form $rolesForm = null,
Form $apiTokenForm = null
) {
FormInterface $editForm = null,
FormInterface $pwdForm = null,
FormInterface $rolesForm = null,
FormInterface $apiTokenForm = null,
FormInterface $teamsForm = null
): Response {
$forms = [];
if ($this->isGranted(UserVoter::EDIT, $user)) {
@@ -268,6 +283,10 @@ class ProfileController extends AbstractController
$apiTokenForm = $apiTokenForm ?: $this->createApiTokenForm($user);
$forms['api-token'] = $apiTokenForm->createView();
}
if ($this->isGranted(UserVoter::TEAMS, $user)) {
$teamsForm = $teamsForm ?: $this->createTeamsForm($user);
$forms['teams'] = $teamsForm->createView();
}
if ($this->isGranted(UserVoter::ROLES, $user)) {
$rolesForm = $rolesForm ?: $this->createRolesForm($user);
$forms['roles'] = $rolesForm->createView();
@@ -280,15 +299,11 @@ class ProfileController extends AbstractController
]);
}
/**
* @param User $user
* @return \Symfony\Component\Form\FormInterface
*/
private function createPreferencesForm(User $user)
private function createPreferencesForm(User $user): FormInterface
{
// we need to prepare the user preferences, which is done via an EventSubscriber
$event = new PrepareUserEvent($user);
$this->dispatcher->dispatch(PrepareUserEvent::PREPARE, $event);
$this->dispatcher->dispatch($event, PrepareUserEvent::PREPARE);
return $this->createForm(
UserPreferencesForm::class,
@@ -300,11 +315,7 @@ class ProfileController extends AbstractController
);
}
/**
* @param User $user
* @return \Symfony\Component\Form\FormInterface
*/
private function createEditForm(User $user)
private function createEditForm(User $user): FormInterface
{
return $this->createForm(
UserEditType::class,
@@ -317,11 +328,7 @@ class ProfileController extends AbstractController
);
}
/**
* @param User $user
* @return \Symfony\Component\Form\FormInterface
*/
private function createRolesForm(User $user)
private function createRolesForm(User $user): FormInterface
{
return $this->createForm(
UserRolesType::class,
@@ -333,28 +340,32 @@ class ProfileController extends AbstractController
);
}
/**
* @param User $user
* @return \Symfony\Component\Form\FormInterface
*/
private function createPasswordForm(User $user)
private function createTeamsForm(User $user): FormInterface
{
return $this->createForm(
UserTeamsType::class,
$user,
[
'action' => $this->generateUrl('user_profile_teams', ['username' => $user->getUsername()]),
'method' => 'POST',
]
);
}
private function createPasswordForm(User $user): FormInterface
{
return $this->createForm(
UserPasswordType::class,
$user,
[
'validation_groups' => ['passwordUpdate'],
'validation_groups' => ['PasswordUpdate'],
'action' => $this->generateUrl('user_profile_password', ['username' => $user->getUsername()]),
'method' => 'POST'
]
);
}
/**
* @param User $user
* @return \Symfony\Component\Form\FormInterface
*/
private function createApiTokenForm(User $user)
private function createApiTokenForm(User $user): FormInterface
{
return $this->createForm(
UserApiTokenType::class,

View File

@@ -9,10 +9,12 @@
namespace App\Controller;
use App\Configuration\FormConfiguration;
use App\Entity\Customer;
use App\Entity\Project;
use App\Event\ProjectMetaDefinitionEvent;
use App\Form\ProjectEditForm;
use App\Form\ProjectTeamPermissionForm;
use App\Form\Toolbar\ProjectToolbarForm;
use App\Form\Type\ProjectType;
use App\Repository\ProjectRepository;
@@ -40,14 +42,19 @@ class ProjectController extends AbstractController
* @var ProjectRepository
*/
private $repository;
/**
* @var FormConfiguration
*/
private $configuration;
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
public function __construct(ProjectRepository $repository, EventDispatcherInterface $dispatcher)
public function __construct(ProjectRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher)
{
$this->repository = $repository;
$this->configuration = $configuration;
$this->dispatcher = $dispatcher;
}
@@ -60,14 +67,11 @@ class ProjectController extends AbstractController
* @Route(path="/", defaults={"page": 1}, name="admin_project", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_project_paginated", methods={"GET"})
* @Security("is_granted('view_project')")
*
* @param int $page
* @param Request $request
* @return Response
*/
public function indexAction($page, Request $request)
{
$query = new ProjectQuery();
$query->setCurrentUser($this->getUser());
$query->setPage($page);
$form = $this->getToolbarForm($query);
@@ -85,14 +89,40 @@ class ProjectController extends AbstractController
]);
}
/**
* @Route(path="/{id}/permissions", name="admin_project_permissions", methods={"GET", "POST"})
* @Security("is_granted('permissions', project)")
*/
public function teamPermissions(Project $project, Request $request)
{
$form = $this->createForm(ProjectTeamPermissionForm::class, $project, [
'action' => $this->generateUrl('admin_project_permissions', ['id' => $project->getId()]),
'method' => 'POST',
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->getRepository()->saveProject($project);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_project');
} catch (ORMException $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->render('project/permissions.html.twig', [
'project' => $project,
'form' => $form->createView()
]);
}
/**
* @Route(path="/create", name="admin_project_create", methods={"GET", "POST"})
* @Route(path="/create/{customer}", name="admin_project_create_with_customer", methods={"GET", "POST"})
* @Security("is_granted('create_project')")
*
* @param Request $request
* @param Customer|null $customer
* @return RedirectResponse|Response
*/
public function createAction(Request $request, ?Customer $customer = null)
{
@@ -108,9 +138,6 @@ class ProjectController extends AbstractController
/**
* @Route(path="/{id}/budget", name="admin_project_budget", methods={"GET"})
* @Security("is_granted('budget', project)")
*
* @param Project $project
* @return Response
*/
public function budgetAction(Project $project)
{
@@ -123,10 +150,6 @@ class ProjectController extends AbstractController
/**
* @Route(path="/{id}/edit", name="admin_project_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', project)")
*
* @param Project $project
* @param Request $request
* @return RedirectResponse|Response
*/
public function editAction(Project $project, Request $request)
{
@@ -136,10 +159,6 @@ class ProjectController extends AbstractController
/**
* @Route(path="/{id}/delete", name="admin_project_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', project)")
*
* @param Project $project
* @param Request $request
* @return RedirectResponse|Response
*/
public function deleteAction(Project $project, Request $request)
{
@@ -158,6 +177,7 @@ class ProjectController extends AbstractController
$query = new ProjectFormTypeQuery();
$query->setCustomer($project->getCustomer());
$query->setProjectToIgnore($project);
$query->setUser($this->getUser());
return $repo->getQueryBuilderForFormType($query);
},
@@ -195,7 +215,7 @@ class ProjectController extends AbstractController
protected function renderProjectForm(Project $project, Request $request)
{
$event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch(ProjectMetaDefinitionEvent::class, $event);
$this->dispatcher->dispatch($event, ProjectMetaDefinitionEvent::class);
$editForm = $this->createEditForm($project);
$editForm->handleRequest($request);
@@ -225,11 +245,7 @@ class ProjectController extends AbstractController
]);
}
/**
* @param ProjectQuery $query
* @return FormInterface
*/
protected function getToolbarForm(ProjectQuery $query)
protected function getToolbarForm(ProjectQuery $query): FormInterface
{
return $this->createForm(ProjectToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_project', [
@@ -239,16 +255,12 @@ class ProjectController extends AbstractController
]);
}
/**
* @param Project $project
* @return FormInterface
*/
private function createEditForm(Project $project)
private function createEditForm(Project $project): FormInterface
{
if ($project->getId() === null) {
$url = $this->generateUrl('admin_project_create');
$currency = Customer::DEFAULT_CURRENCY;
} else {
$currency = $this->configuration->getCustomerDefaultCurrency();
$url = $this->generateUrl('admin_project_create');
if ($project->getId() !== null) {
$url = $this->generateUrl('admin_project_edit', ['id' => $project->getId()]);
$currency = $project->getCustomer()->getCurrency();
}

View File

@@ -169,7 +169,7 @@ class SystemConfigurationController extends AbstractController
$types = $this->getConfigurationTypes();
$event = new SystemConfigurationEvent($types);
$this->eventDispatcher->dispatch(SystemConfigurationEvent::CONFIGURE, $event);
$this->eventDispatcher->dispatch($event, SystemConfigurationEvent::CONFIGURE);
foreach ($event->getConfigurations() as $configs) {
foreach ($configs->getConfiguration() as $config) {
@@ -248,6 +248,10 @@ class SystemConfigurationController extends AbstractController
->setName('defaults.user.theme')
->setLabel('skin')
->setType(SkinType::class),
(new Configuration())
->setName('defaults.user.currency')
->setLabel('currency')
->setType(CurrencyType::class),
]),
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_THEME)

View File

@@ -0,0 +1,185 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller;
use App\Entity\Team;
use App\Form\TeamCustomerForm;
use App\Form\TeamEditForm;
use App\Form\TeamProjectForm;
use App\Form\Toolbar\TeamToolbarForm;
use App\Repository\Query\TeamQuery;
use App\Repository\TeamRepository;
use Doctrine\ORM\ORMException;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/admin/teams")
* @Security("is_granted('view_team')")
*/
class TeamController extends AbstractController
{
/**
* @var TeamRepository
*/
private $repository;
public function __construct(TeamRepository $repository)
{
$this->repository = $repository;
}
/**
* @Route(path="/", defaults={"page": 1}, name="admin_team", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_team_paginated", methods={"GET"})
*
* @param TeamRepository $repository
* @param Request $request
* @param int $page
* @return Response
*/
public function listTeams(TeamRepository $repository, Request $request, $page)
{
$query = new TeamQuery();
$query->setPage($page);
$query->setOrderBy('name');
$form = $this->getToolbarForm($query);
$form->setData($query);
$form->submit($request->query->all(), false);
$teams = $repository->getPagerfantaForQuery($query);
return $this->render('team/index.html.twig', [
'teams' => $teams,
'query' => $query,
'showFilter' => $query->isDirty(),
'toolbarForm' => $form->createView(),
]);
}
/**
* @Route(path="/create", name="admin_team_create", methods={"GET", "POST"})
* @Security("is_granted('create_team')")
*
* @param Request $request
* @return RedirectResponse|Response
*/
public function createTeam(Request $request)
{
return $this->renderEditScreen(new Team(), $request);
}
/**
* @Route(path="/{id}/edit", name="admin_team_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', team)")
*/
public function editAction(Team $team, Request $request)
{
return $this->renderEditScreen($team, $request);
}
private function renderEditScreen(Team $team, Request $request): Response
{
$customerForm = null;
$projectForm = null;
if ($team->getId() === null) {
$url = $this->generateUrl('admin_team_create');
} else {
$url = $this->generateUrl('admin_team_edit', ['id' => $team->getId()]);
}
$editForm = $this->createForm(TeamEditForm::class, $team, [
'action' => $url,
'method' => 'POST',
]);
if ($request->isMethod('POST') && (null !== ($editFormValues = $request->get($editForm->getName())))) {
$editForm->submit($editFormValues, true);
if ($editForm->isValid()) {
try {
// make sure that the teamlead is always part of the team, otherwise permission checks
// and filtering might not work as expected!
$team->addUser($team->getTeamLead());
$this->repository->saveTeam($team);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
} catch (ORMException $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
}
if (null !== $team->getId()) {
$customerForm = $this->createForm(TeamCustomerForm::class, $team, [
'method' => 'POST',
]);
if ($request->isMethod('POST') && (null !== ($customerFormValues = $request->get($customerForm->getName())))) {
$customerForm->submit($customerFormValues, true);
if ($customerForm->isValid()) {
try {
$this->repository->saveTeam($team);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
} catch (ORMException $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
}
$projectForm = $this->createForm(TeamProjectForm::class, $team, [
'method' => 'POST',
]);
if ($request->isMethod('POST') && (null !== ($projectFormValues = $request->get($projectForm->getName())))) {
$projectForm->submit($projectFormValues, true);
if ($projectForm->isValid()) {
try {
$this->repository->saveTeam($team);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
} catch (ORMException $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
}
}
return $this->render('team/edit.html.twig', [
'team' => $team,
'form' => $editForm->createView(),
'customerForm' => $customerForm ? $customerForm->createView() : null,
'projectForm' => $projectForm ? $projectForm->createView() : null,
]);
}
private function getToolbarForm(TeamQuery $query): FormInterface
{
return $this->createForm(TeamToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_team', [
'page' => $query->getPage(),
]),
'method' => 'GET',
]);
}
}

View File

@@ -112,9 +112,7 @@ abstract class TimesheetAbstractController extends AbstractController
$dirtyQuery = $query->isDirty();
if (!$this->includeUserInForms()) {
$query->setUser($this->getUser());
}
$this->prepareQuery($query);
$pager = $this->getRepository()->getPagerfantaForQuery($query);
@@ -138,7 +136,7 @@ abstract class TimesheetAbstractController extends AbstractController
protected function edit(Timesheet $entry, Request $request, string $renderTemplate)
{
$event = new TimesheetMetaDefinitionEvent($entry);
$this->dispatcher->dispatch(TimesheetMetaDefinitionEvent::class, $event);
$this->dispatcher->dispatch($event, TimesheetMetaDefinitionEvent::class);
$editForm = $this->getEditForm($entry, $request->get('page'));
$editForm->handleRequest($request);
@@ -196,7 +194,7 @@ abstract class TimesheetAbstractController extends AbstractController
}
$event = new TimesheetMetaDefinitionEvent($entry);
$this->dispatcher->dispatch(TimesheetMetaDefinitionEvent::class, $event);
$this->dispatcher->dispatch($event, TimesheetMetaDefinitionEvent::class);
$mode = $this->getTrackingMode();
$mode->create($entry, $request);
@@ -253,9 +251,7 @@ abstract class TimesheetAbstractController extends AbstractController
}
$query->getEnd()->setTime(23, 59, 59);
if (!$this->includeUserInForms()) {
$query->setUser($this->getUser());
}
$this->prepareQuery($query);
$entries = $this->getRepository()->getTimesheetsForQuery($query);
@@ -265,6 +261,11 @@ abstract class TimesheetAbstractController extends AbstractController
]);
}
protected function prepareQuery(TimesheetQuery $query)
{
$query->setUser($this->getUser());
}
protected function getCreateForm(Timesheet $entry, TrackingModeInterface $mode): FormInterface
{
return $this->createForm($this->getCreateFormClassName(), $entry, [

View File

@@ -13,6 +13,7 @@ use App\Entity\Timesheet;
use App\Form\TimesheetAdminEditForm;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TagRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
@@ -76,6 +77,11 @@ class TimesheetTeamController extends TimesheetAbstractController
return $this->create($request, 'timesheet-team/edit.html.twig', $projectRepository, $activityRepository, $tagRepository);
}
protected function prepareQuery(TimesheetQuery $query)
{
$query->setCurrentUser($this->getUser());
}
protected function getCreateFormClassName(): string
{
return TimesheetAdminEditForm::class;

View File

@@ -9,6 +9,7 @@
namespace App\DependencyInjection;
use App\Entity\Customer;
use App\Entity\User;
use App\Timesheet\Rounding\RoundingInterface;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
@@ -250,6 +251,7 @@ class Configuration implements ConfigurationInterface
->children()
->booleanNode('week_numbers')->defaultTrue()->end()
->integerNode('day_limit')->defaultValue(4)->end()
->scalarNode('slot_duration')->defaultValue('00:30:00')->end()
->arrayNode('businessHours')
->addDefaultsIfNotSet()
->children()
@@ -438,7 +440,7 @@ class Configuration implements ConfigurationInterface
->children()
->scalarNode('timezone')->defaultNull()->end()
->scalarNode('country')->defaultValue('DE')->end()
->scalarNode('currency')->defaultValue('EUR')->end()
->scalarNode('currency')->defaultValue(Customer::DEFAULT_CURRENCY)->end()
->end()
->end()
->arrayNode('user')
@@ -447,9 +449,9 @@ class Configuration implements ConfigurationInterface
->scalarNode('timezone')->defaultNull()->end()
->scalarNode('language')->defaultValue(User::DEFAULT_LANGUAGE)->end()
->scalarNode('theme')->defaultNull()->end()
->scalarNode('currency')->defaultValue(Customer::DEFAULT_CURRENCY)->end()
->end()
->end()
->end()
;

View File

@@ -52,6 +52,13 @@ abstract class AbstractMigration extends BaseAbstractMigration implements Contai
return 'kimai2_' . $name;
}
/**
* Whether we should deactivate foreign key support for SQLite.
* This is required, if columns are changed.
* SQLite will drop the table and therefor all referenced data if we don't deactivate this.
*
* @return bool
*/
protected function isSupportingForeignKeys(): bool
{
return true;

View File

@@ -50,16 +50,18 @@ class Activity implements EntityWithMetaFields
/**
* @var string
*
* @ORM\Column(name="name", type="string", length=255, nullable=false)
* Do not increase length to more than 190 chars, otherwise "Index column size too large." will be triggered.
*
* @ORM\Column(name="name", type="string", length=150, nullable=false)
* @Assert\NotBlank()
* @Assert\Length(min=2, max=255)
* @Assert\Length(min=2, max=150)
*/
private $name;
/**
* @var string
*
* @ORM\Column(name="comment", type="text", length=65535, nullable=true)
* @ORM\Column(name="comment", type="text", nullable=true)
*/
private $comment;

View File

@@ -40,9 +40,11 @@ class Customer implements EntityWithMetaFields
/**
* @var string
*
* @ORM\Column(name="name", type="string", length=255, nullable=false)
* Do not increase length to more than 190 chars, otherwise "Index column size too large." will be triggered.
*
* @ORM\Column(name="name", type="string", length=150, nullable=false)
* @Assert\NotBlank()
* @Assert\Length(min=2, max=255)
* @Assert\Length(min=2, max=150)
*/
private $name;
@@ -50,13 +52,14 @@ class Customer implements EntityWithMetaFields
* @var string
*
* @ORM\Column(name="number", type="string", length=50, nullable=true)
* @Assert\Length(max=50)
*/
private $number;
/**
* @var string
*
* @ORM\Column(name="comment", type="text", length=65535, nullable=true)
* @ORM\Column(name="comment", type="text", nullable=true)
*/
private $comment;
@@ -72,6 +75,7 @@ class Customer implements EntityWithMetaFields
* @var string
*
* @ORM\Column(name="company", type="string", length=255, nullable=true)
* @Assert\Length(max=255)
*/
private $company;
@@ -79,13 +83,14 @@ class Customer implements EntityWithMetaFields
* @var string
*
* @ORM\Column(name="contact", type="string", length=255, nullable=true)
* @Assert\Length(max=255)
*/
private $contact;
/**
* @var string
*
* @ORM\Column(name="address", type="text", length=65535, nullable=true)
* @ORM\Column(name="address", type="text", nullable=true)
*/
private $address;
@@ -94,6 +99,7 @@ class Customer implements EntityWithMetaFields
*
* @ORM\Column(name="country", type="string", length=2, nullable=false)
* @Assert\NotBlank()
* @Assert\Length(max=2)
*/
private $country;
@@ -102,6 +108,7 @@ class Customer implements EntityWithMetaFields
*
* @ORM\Column(name="currency", type="string", length=3, nullable=false)
* @Assert\NotBlank()
* @Assert\Length(max=3)
*/
private $currency = self::DEFAULT_CURRENCY;
@@ -109,6 +116,7 @@ class Customer implements EntityWithMetaFields
* @var string
*
* @ORM\Column(name="phone", type="string", length=255, nullable=true)
* @Assert\Length(max=255)
*/
private $phone;
@@ -116,6 +124,7 @@ class Customer implements EntityWithMetaFields
* @var string
*
* @ORM\Column(name="fax", type="string", length=255, nullable=true)
* @Assert\Length(max=255)
*/
private $fax;
@@ -123,13 +132,17 @@ class Customer implements EntityWithMetaFields
* @var string
*
* @ORM\Column(name="mobile", type="string", length=255, nullable=true)
* @Assert\Length(max=255)
*/
private $mobile;
/**
* @var string
*
* Limited via RFC to 254 chars
*
* @ORM\Column(name="email", type="string", length=255, nullable=true)
* @Assert\Length(max=254)
*/
private $email;
@@ -137,14 +150,18 @@ class Customer implements EntityWithMetaFields
* @var string
*
* @ORM\Column(name="homepage", type="string", length=255, nullable=true)
* @Assert\Length(max=255)
*/
private $homepage;
/**
* @var string
*
* @ORM\Column(name="timezone", type="string", length=255, nullable=false)
* Length was determined by a MySQL column via "use mysql;describe time_zone_name;"
*
* @ORM\Column(name="timezone", type="string", length=64, nullable=false)
* @Assert\NotBlank()
* @Assert\Length(max=64)
*/
private $timezone;
@@ -160,9 +177,26 @@ class Customer implements EntityWithMetaFields
*/
private $meta;
/**
* @var Team[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="Team", cascade={"persist"}, inversedBy="customers")
* @ORM\JoinTable(
* name="kimai2_customers_teams",
* joinColumns={
* @ORM\JoinColumn(name="customer_id", referencedColumnName="id", onDelete="CASCADE")
* },
* inverseJoinColumns={
* @ORM\JoinColumn(name="team_id", referencedColumnName="id", onDelete="CASCADE")
* }
* )
*/
private $teams;
public function __construct()
{
$this->meta = new ArrayCollection();
$this->teams = new ArrayCollection();
}
public function getId(): ?int
@@ -399,6 +433,33 @@ class Customer implements EntityWithMetaFields
return $this;
}
public function addTeam(Team $team)
{
if ($this->teams->contains($team)) {
return $this;
}
$this->teams->add($team);
$team->addCustomer($this);
}
public function removeTeam(Team $team)
{
if (!$this->teams->contains($team)) {
return;
}
$this->teams->removeElement($team);
$team->removeCustomer($this);
}
/**
* @return Collection<Team>
*/
public function getTeams(): Collection
{
return $this->teams;
}
/**
* @return string
*/

View File

@@ -59,7 +59,7 @@ class InvoiceTemplate
/**
* @var string
*
* @ORM\Column(name="address", type="text", length=65535, nullable=true)
* @ORM\Column(name="address", type="text", nullable=true)
*/
private $address;
@@ -105,7 +105,7 @@ class InvoiceTemplate
/**
* @var string
*
* @ORM\Column(name="payment_terms", type="text", length=65535, nullable=true)
* @ORM\Column(name="payment_terms", type="text", nullable=true)
*/
private $paymentTerms;

View File

@@ -49,9 +49,11 @@ class Project implements EntityWithMetaFields
/**
* @var string
*
* @ORM\Column(name="name", type="string", length=255, nullable=false)
* Do not increase length to more than 190 chars, otherwise "Index column size too large." will be triggered.
*
* @ORM\Column(name="name", type="string", length=150, nullable=false)
* @Assert\NotNull()
* @Assert\Length(min=2, max=255)
* @Assert\Length(min=2, max=150)
*/
private $name;
@@ -66,7 +68,7 @@ class Project implements EntityWithMetaFields
/**
* @var string
*
* @ORM\Column(name="comment", type="text", length=65535, nullable=true)
* @ORM\Column(name="comment", type="text", nullable=true)
*/
private $comment;
@@ -90,9 +92,26 @@ class Project implements EntityWithMetaFields
*/
private $meta;
/**
* @var Team[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="Team", cascade={"persist"}, inversedBy="projects")
* @ORM\JoinTable(
* name="kimai2_projects_teams",
* joinColumns={
* @ORM\JoinColumn(name="project_id", referencedColumnName="id", onDelete="CASCADE")
* },
* inverseJoinColumns={
* @ORM\JoinColumn(name="team_id", referencedColumnName="id", onDelete="CASCADE")
* }
* )
*/
private $teams;
public function __construct()
{
$this->meta = new ArrayCollection();
$this->teams = new ArrayCollection();
}
public function getId(): ?int
@@ -223,6 +242,33 @@ class Project implements EntityWithMetaFields
return $this;
}
public function addTeam(Team $team)
{
if ($this->teams->contains($team)) {
return;
}
$this->teams->add($team);
$team->addProject($this);
}
public function removeTeam(Team $team)
{
if (!$this->teams->contains($team)) {
return;
}
$this->teams->removeElement($team);
$team->removeProject($this);
}
/**
* @return Collection<Team>
*/
public function getTeams(): Collection
{
return $this->teams;
}
/**
* @return string
*/

210
src/Entity/Team.php Normal file
View File

@@ -0,0 +1,210 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(name="kimai2_teams",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"name"})
* }
* )
* @ORM\Entity(repositoryClass="App\Repository\TeamRepository")
* @UniqueEntity("name")
*/
class Team
{
/**
* @var int
*
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* @var string
*
* @ORM\Column(name="name", type="string", length=100, nullable=false)
* @Assert\NotBlank()
* @Assert\Length(min=2, max=100)
*/
private $name;
/**
* @var User
*
* @ORM\ManyToOne(targetEntity="App\Entity\User")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $teamlead;
/**
* @var User[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="User", mappedBy="teams", fetch="EXTRA_LAZY")
*/
private $users;
/**
* @var Customer[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="Customer", mappedBy="teams", fetch="EXTRA_LAZY")
*/
private $customers;
/**
* @var Project[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="Project", mappedBy="teams", fetch="EXTRA_LAZY")
*/
private $projects;
public function __construct()
{
$this->users = new ArrayCollection();
$this->customers = new ArrayCollection();
$this->projects = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function setName(string $name): Team
{
$this->name = $name;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function getTeamLead(): ?User
{
return $this->teamlead;
}
public function isTeamlead(User $user): bool
{
return $this->teamlead === $user;
}
public function setTeamLead(User $teamlead): Team
{
$this->teamlead = $teamlead;
$this->addUser($teamlead);
return $this;
}
public function hasUser(User $user): bool
{
return $this->users->contains($user);
}
public function addUser(User $user)
{
if ($this->users->contains($user)) {
return;
}
$this->users->add($user);
$user->addTeam($this);
}
public function removeUser(User $user)
{
if (!$this->users->contains($user)) {
return;
}
$this->users->removeElement($user);
$user->removeTeam($this);
}
/**
* @return Collection<User>
*/
public function getUsers(): iterable
{
return $this->users;
}
public function addCustomer(Customer $customer)
{
if ($this->customers->contains($customer)) {
return;
}
$this->customers->add($customer);
$customer->addTeam($this);
}
public function removeCustomer(Customer $customer)
{
if (!$this->customers->contains($customer)) {
return;
}
$this->customers->removeElement($customer);
$customer->removeTeam($this);
}
/**
* @return Collection<Customer>
*/
public function getCustomers(): iterable
{
return $this->customers;
}
public function addProject(Project $project)
{
if ($this->projects->contains($project)) {
return;
}
$this->projects->add($project);
$project->addTeam($this);
}
public function removeProject(Project $project)
{
if (!$this->projects->contains($project)) {
return;
}
$this->projects->removeElement($project);
$project->removeTeam($this);
}
/**
* @return Collection<Project>
*/
public function getProjects(): iterable
{
return $this->projects;
}
/**
* @return string
*/
public function __toString()
{
return $this->getName();
}
}

View File

@@ -9,6 +9,8 @@
namespace App\Entity;
use DateTime;
use DateTimeZone;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -48,7 +50,7 @@ class Timesheet implements EntityWithMetaFields
private $id;
/**
* @var \DateTime
* @var DateTime
*
* @ORM\Column(name="start_time", type="datetime", nullable=false)
* @Assert\NotNull()
@@ -56,7 +58,7 @@ class Timesheet implements EntityWithMetaFields
private $begin;
/**
* @var \DateTime
* @var DateTime
*
* @ORM\Column(name="end_time", type="datetime", nullable=true)
*/
@@ -112,7 +114,7 @@ class Timesheet implements EntityWithMetaFields
/**
* @var string
*
* @ORM\Column(name="description", type="text", length=65535, nullable=true)
* @ORM\Column(name="description", type="text", nullable=true)
*/
private $description;
@@ -142,10 +144,10 @@ class Timesheet implements EntityWithMetaFields
* @ORM\JoinTable(
* name="kimai2_timesheet_tags",
* joinColumns={
* @ORM\JoinColumn(name="timesheet_id", referencedColumnName="id")
* @ORM\JoinColumn(name="timesheet_id", referencedColumnName="id", onDelete="CASCADE")
* },
* inverseJoinColumns={
* @ORM\JoinColumn(name="tag_id", referencedColumnName="id")
* @ORM\JoinColumn(name="tag_id", referencedColumnName="id", onDelete="CASCADE")
* }
* )
*/
@@ -188,17 +190,17 @@ class Timesheet implements EntityWithMetaFields
}
if (null !== $this->begin) {
$this->begin->setTimeZone(new \DateTimeZone($this->timezone));
$this->begin->setTimeZone(new DateTimeZone($this->timezone));
}
if (null !== $this->end) {
$this->end->setTimeZone(new \DateTimeZone($this->timezone));
$this->end->setTimeZone(new DateTimeZone($this->timezone));
}
$this->localized = true;
}
public function getBegin(): ?\DateTime
public function getBegin(): ?DateTime
{
$this->localizeDates();
@@ -206,10 +208,10 @@ class Timesheet implements EntityWithMetaFields
}
/**
* @param \DateTime $begin
* @param DateTime $begin
* @return Timesheet
*/
public function setBegin(\DateTime $begin): Timesheet
public function setBegin(DateTime $begin): Timesheet
{
$this->begin = $begin;
$this->timezone = $begin->getTimezone()->getName();
@@ -217,7 +219,7 @@ class Timesheet implements EntityWithMetaFields
return $this;
}
public function getEnd(): ?\DateTime
public function getEnd(): ?DateTime
{
$this->localizeDates();
@@ -225,10 +227,10 @@ class Timesheet implements EntityWithMetaFields
}
/**
* @param \DateTime $end
* @param DateTime $end
* @return Timesheet
*/
public function setEnd(?\DateTime $end): Timesheet
public function setEnd(?DateTime $end): Timesheet
{
$this->end = $end;
@@ -422,10 +424,10 @@ class Timesheet implements EntityWithMetaFields
}
/**
* BE WARNED: this method should NOT be used programmatically, there is very likely no reason for it!
* BE WARNED: this method should NOT be used.
* It was ONLY introduced for the command "kimai:import-v1".
*
* @internal
* @deprecated since it was introduced, only meant for the initial migration. Will be removed with 1.0.
* @param string $timezone
* @return Timesheet
*/

View File

@@ -95,6 +95,22 @@ class User extends BaseUser implements UserInterface
*/
private $preferences;
/**
* @var Team[]|ArrayCollection
*
* @ORM\ManyToMany(targetEntity="Team", inversedBy="users", cascade={"persist"})
* @ORM\JoinTable(
* name="kimai2_users_teams",
* joinColumns={
* @ORM\JoinColumn(name="user_id", referencedColumnName="id", onDelete="CASCADE")
* },
* inverseJoinColumns={
* @ORM\JoinColumn(name="team_id", referencedColumnName="id", onDelete="CASCADE")
* }
* )
*/
private $teams;
/**
* User constructor.
*/
@@ -103,6 +119,7 @@ class User extends BaseUser implements UserInterface
parent::__construct();
$this->registeredAt = new \DateTime();
$this->preferences = new ArrayCollection();
$this->teams = new ArrayCollection();
}
public function getId(): ?int
@@ -274,6 +291,55 @@ class User extends BaseUser implements UserInterface
return $this;
}
public function addTeam(Team $team): User
{
if ($this->teams->contains($team)) {
return $this;
}
$this->teams->add($team);
$team->addUser($this);
return $this;
}
public function removeTeam(Team $team)
{
if (!$this->teams->contains($team)) {
return;
}
$this->teams->removeElement($team);
$team->removeUser($this);
}
/**
* @return Collection<Team>
*/
public function getTeams(): Collection
{
return $this->teams;
}
public function isInTeam(Team $team): bool
{
return $this->teams->contains($team);
}
public function isTeamleadOf(Team $team): bool
{
return $team->getTeamLead() === $this;
}
public function isTeamlead(): bool
{
return $this->hasRole(static::ROLE_TEAMLEAD);
}
public function isAdmin(): bool
{
return $this->hasRole(static::ROLE_ADMIN);
}
/**
* @return string
*/

View File

@@ -38,7 +38,6 @@ class UserPreference
* @ORM\Column(name="id", type="integer")
*/
private $id;
/**
* @var User
*
@@ -47,7 +46,6 @@ class UserPreference
* @Assert\NotNull()
*/
private $user;
/**
* @var string
*
@@ -55,28 +53,29 @@ class UserPreference
* @Assert\Length(min=2, max=50)
*/
private $name;
/**
* @var string
*
* @ORM\Column(name="value", type="string", length=255, nullable=true)
*/
private $value;
/**
* @var string
*/
protected $type;
private $type;
/**
* @var bool
*/
protected $enabled = true;
private $enabled = true;
/**
* @var Constraint[]
*/
protected $constraints = [];
private $constraints = [];
/**
* An array of options for the form element
* @var array
*/
private $options = [];
/**
* @return int
@@ -237,4 +236,27 @@ class UserPreference
{
return $this->constraints;
}
/**
* Set an array of options for the FormType.
*
* @param array $options
* @return UserPreference
*/
public function setOptions(array $options): UserPreference
{
$this->options = $options;
return $this;
}
/**
* Returns an array with options for the FormType.
*
* @return array
*/
public function getOptions(): array
{
return $this->options;
}
}

View File

@@ -10,7 +10,7 @@
namespace App\Event;
use App\Entity\Activity;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Contracts\EventDispatcher\Event;
/**
* This event can be used, to dynamically add meta fields to activities

View File

@@ -1,57 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use KevinPapst\AdminLTEBundle\Model\MenuItemModel;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Component\HttpFoundation\Request;
/**
* The ConfigureAdminMenuEvent is used for populating the administration navigation.
*/
final class ConfigureAdminMenuEvent extends Event
{
public const CONFIGURE = 'app.admin_menu_configure';
/**
* @var Request
*/
private $request;
/**
* @var MenuItemModel
*/
private $menu;
/**
* @param Request $request
* @param MenuItemModel $menu
*/
public function __construct(Request $request, MenuItemModel $menu)
{
$this->request = $request;
$this->menu = $menu;
}
/**
* @return Request
*/
public function getRequest()
{
return $this->request;
}
/**
* @return MenuItemModel
*/
public function getAdminMenu()
{
return $this->menu;
}
}

View File

@@ -11,8 +11,8 @@ namespace App\Event;
use KevinPapst\AdminLTEBundle\Event\SidebarMenuEvent;
use KevinPapst\AdminLTEBundle\Model\MenuItemModel;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\EventDispatcher\Event;
/**
* The ConfigureMainMenuEvent is used for populating the main navigation.

View File

@@ -10,7 +10,7 @@
namespace App\Event;
use App\Entity\Customer;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Contracts\EventDispatcher\Event;
/**
* This event can be used, to dynamically add meta fields to customers

View File

@@ -11,7 +11,7 @@ namespace App\Event;
use App\Entity\User;
use App\Widget\WidgetContainerInterface;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Contracts\EventDispatcher\Event;
final class DashboardEvent extends Event
{

View File

@@ -10,7 +10,7 @@
namespace App\Event;
use App\Entity\User;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Contracts\EventDispatcher\Event;
/**
* This event should be used, if a user profile is loaded and want to fill the dynamic user preferences

View File

@@ -10,7 +10,7 @@
namespace App\Event;
use App\Entity\Project;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Contracts\EventDispatcher\Event;
/**
* This event can be used, to dynamically add meta fields to projects

View File

@@ -10,7 +10,7 @@
namespace App\Event;
use App\Form\Model\SystemConfiguration;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Contracts\EventDispatcher\Event;
/**
* This event should be used, if system configurations should be changed/added dynamically.

View File

@@ -10,7 +10,7 @@
namespace App\Event;
use App\Entity\User;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Contracts\EventDispatcher\Event;
final class ThemeEvent extends Event
{

View File

@@ -10,7 +10,7 @@
namespace App\Event;
use App\Entity\Timesheet;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Contracts\EventDispatcher\Event;
/**
* This event can be used, to dynamically add meta fields to timesheets

View File

@@ -11,7 +11,7 @@ namespace App\Event;
use App\Entity\User;
use App\Entity\UserPreference;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Contracts\EventDispatcher\Event;
/**
* This event should be used, if further user preferences should added dynamically

View File

@@ -9,7 +9,6 @@
namespace App\EventSubscriber;
use App\Event\ConfigureAdminMenuEvent;
use App\Event\ConfigureMainMenuEvent;
use KevinPapst\AdminLTEBundle\Event\SidebarMenuEvent;
use KevinPapst\AdminLTEBundle\Event\ThemeEvents;
@@ -73,16 +72,7 @@ class MenuBuilderSubscriber implements EventSubscriberInterface
new MenuItemModel('system', 'menu.system', '')
);
$this->eventDispatcher->dispatch(ConfigureMainMenuEvent::CONFIGURE, $menuEvent);
// @deprecated since 0.9, will be removed with 1.0
$this->eventDispatcher->dispatch(
ConfigureAdminMenuEvent::CONFIGURE,
new ConfigureAdminMenuEvent(
$request,
$menuEvent->getAdminMenu()
)
);
$this->eventDispatcher->dispatch($menuEvent, ConfigureMainMenuEvent::CONFIGURE);
if ($menuEvent->getAdminMenu()->hasChildren()) {
$event->addItem(new MenuItemModel('admin', 'menu.admin', ''));

View File

@@ -84,7 +84,7 @@ final class MenuSubscriber implements EventSubscriberInterface
);
}
if ($auth->isGranted('view_export')) {
if ($auth->isGranted('create_export')) {
$menu->addItem(
new MenuItemModel('export', 'menu.export', 'export', [], $this->getIcon('export'))
);
@@ -142,6 +142,12 @@ final class MenuSubscriber implements EventSubscriberInterface
);
}
if ($auth->isGranted('view_team')) {
$menu->addChild(
new MenuItemModel('user_team', 'menu.admin_team', 'admin_team', [], $this->getIcon('team'))
);
}
if ($auth->isGranted('plugins')) {
$menu->addChild(
new MenuItemModel('plugins', 'menu.plugin', 'plugins', [], $this->getIcon('plugin'))

View File

@@ -11,7 +11,7 @@ namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
@@ -72,9 +72,6 @@ class RedirectToLocaleSubscriber implements EventSubscriberInterface
$this->locales = array_unique($this->locales);
}
/**
* @return array
*/
public static function getSubscribedEvents(): array
{
return [
@@ -82,10 +79,7 @@ class RedirectToLocaleSubscriber implements EventSubscriberInterface
];
}
/**
* @param GetResponseEvent $event
*/
public function onKernelRequest(GetResponseEvent $event)
public function onKernelRequest(RequestEvent $event)
{
$request = $event->getRequest();

View File

@@ -11,7 +11,7 @@ namespace App\EventSubscriber;
use App\Entity\User;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
@@ -34,7 +34,7 @@ class TimezoneSubscriber implements EventSubscriberInterface
];
}
public function setTimezone(GetResponseEvent $event)
public function setTimezone(RequestEvent $event)
{
if (!$this->canHandleEvent()) {
return;

View File

@@ -66,6 +66,11 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
return $this->formConfig->getUserDefaultTheme();
}
private function getDefaultCurrency(): ?string
{
return $this->formConfig->getUserDefaultCurrency();
}
private function getDefaultLanguage(): string
{
return $this->formConfig->getUserDefaultLanguage();
@@ -88,9 +93,11 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
public function getDefaultPreferences(User $user)
{
$enableHourlyRate = false;
$hourlyRateOptions = [];
if ($this->voter->isGranted('hourly-rate', $user)) {
$enableHourlyRate = true;
$hourlyRateOptions = ['currency' => $this->getDefaultCurrency()];
}
return [
@@ -99,6 +106,7 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setValue(0)
->setType(MoneyType::class)
->setEnabled($enableHourlyRate)
->setOptions($hourlyRateOptions)
->addConstraint(new Range(['min' => 0])),
(new UserPreference())
@@ -155,7 +163,7 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
}
$event = new UserPreferenceEvent($user, $this->getDefaultPreferences($user));
$this->eventDispatcher->dispatch(UserPreferenceEvent::CONFIGURE, $event);
$this->eventDispatcher->dispatch($event, UserPreferenceEvent::CONFIGURE);
foreach ($event->getPreferences() as $preference) {
/* @var UserPreference[] $prefs */
@@ -165,6 +173,7 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setType($preference->getType())
->setConstraints($preference->getConstraints())
->setEnabled($preference->isEnabled())
->setOptions($preference->getOptions())
;
} else {
$prefs[$preference->getName()] = $preference;

View File

@@ -62,7 +62,7 @@ class UserProfileSubscriber implements EventSubscriberInterface
$user = $this->storage->getToken()->getUser();
$event = new PrepareUserEvent($user);
$this->eventDispatcher->dispatch(PrepareUserEvent::PREPARE, $event);
$this->eventDispatcher->dispatch($event, PrepareUserEvent::PREPARE);
}
/**

View File

@@ -12,9 +12,12 @@ namespace App\Export\Renderer;
use App\Entity\Timesheet;
use App\Repository\Query\TimesheetQuery;
use App\Twig\DateExtensions;
use App\Twig\Extensions;
use DateTime;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
@@ -22,72 +25,88 @@ use Symfony\Contracts\Translation\TranslatorInterface;
abstract class AbstractSpreadsheetRenderer
{
public const DATETIME_FORMAT = 'yyyy-mm-dd hh:mm';
public const TIME_FORMAT = 'hh:mm';
public const DURATION_FORMAT = '[hh]:mm';
public const RATE_FORMAT_DEFAULT = '#.##0,00 [$%1$s];-#.##0,00 [$%1$s]';
public const RATE_FORMAT_LEFT = '_("%1$s"* #,##0.00_);_("%1$s"* \(#,##0.00\);_("%1$s"* "-"??_);_(@_)';
public const RATE_FORMAT = self::RATE_FORMAT_LEFT;
/**
* @var DateExtensions
*/
protected $dateExtension;
/**
* @var Extensions
*/
protected $extension;
/**
* @var TranslatorInterface
*/
protected $translator;
/**
* @param TranslatorInterface $translator
* @param DateExtensions $dateExtension
* @param Extensions $extensions
*/
public function __construct(
TranslatorInterface $translator,
DateExtensions $dateExtension,
Extensions $extensions
) {
public function __construct(TranslatorInterface $translator, DateExtensions $dateExtension)
{
$this->translator = $translator;
$this->dateExtension = $dateExtension;
$this->extension = $extensions;
}
/**
* @param \DateTime $date
* @return mixed
*/
protected function getFormattedDateTime(\DateTime $date)
protected function setFormattedDateTime(Worksheet $sheet, $column, $row, ?DateTime $date)
{
return $this->dateExtension->dateShort($date) . ' ' . $this->dateExtension->time($date);
}
if (null === $date) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
/**
* @param int $amount
* @return mixed
*/
protected function getFormattedMoney($amount, $currency)
{
return $this->extension->money($amount, $currency);
}
/**
* @param Timesheet $timesheet
* @return string
*/
protected function getUsername(Timesheet $timesheet)
{
if (!empty($timesheet->getUser()->getAlias())) {
return $timesheet->getUser()->getAlias();
return;
}
return $timesheet->getUser()->getUsername();
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DATETIME_FORMAT);
}
/**
* @param int $seconds
* @return mixed
*/
protected function getFormattedDuration($seconds)
protected function setFormattedTime(Worksheet $sheet, $column, $row, ?DateTime $date)
{
return $this->extension->duration($seconds);
if (null === $date) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
return;
}
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::TIME_FORMAT);
}
protected function setFormattedDate(Worksheet $sheet, $column, $row, ?DateTime $date)
{
if (null === $date) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
return;
}
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_DATE_YYYYMMDD2);
}
protected function setDurationTotal(Worksheet $sheet, $column, $row, $startCoordinate, $endCoordinate)
{
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=SUM(%s:%s)', $startCoordinate, $endCoordinate));
$style = $sheet->getStyleByColumnAndRow($column, $row);
$style->getNumberFormat()->setFormatCode(self::DURATION_FORMAT);
}
protected function setDuration(Worksheet $sheet, $column, $row, $duration)
{
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=%s/86400', $duration));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DURATION_FORMAT);
}
protected function setRateTotal(Worksheet $sheet, $column, $row, $startCoordinate, $endCoordinate)
{
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=SUM(%s:%s)', $startCoordinate, $endCoordinate));
}
protected function setRate(Worksheet $sheet, $column, $row, $rate, $currency)
{
$sheet->setCellValueByColumnAndRow($column, $row, $rate);
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(
sprintf(self::RATE_FORMAT_LEFT, $currency)
);
}
/**
@@ -113,8 +132,11 @@ abstract class AbstractSpreadsheetRenderer
$recordsHeaderColumn = 1;
$recordsHeaderRow = 1;
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.date'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.begin'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.end'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.duration'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.rate'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.user'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.customer'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.project'));
@@ -122,45 +144,67 @@ abstract class AbstractSpreadsheetRenderer
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.description'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.exported'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.tags'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.hourlyRate'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.fixedRate'));
foreach ($publicMetaFields as $metaFieldName) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaFieldName));
}
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.hourlyRate'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.fixedRate'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.duration'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn, $recordsHeaderRow, $this->translator->trans('label.rate'));
$entryHeaderRow = $recordsHeaderRow + 1;
$durationTotal = 0;
$currency = false;
$rateTotal = 0;
$durationColumn = null;
$rateColumn = null;
foreach ($timesheets as $timesheet) {
$entryHeaderColumn = 1;
$durationTotal += $timesheet->getDuration();
$rateTotal += $timesheet->getRate();
if ($currency === false) {
$currency = $timesheet->getProject()->getCustomer()->getCurrency();
}
if ($currency !== $timesheet->getProject()->getCustomer()->getCurrency()) {
$currency = null;
}
$customerCurrency = $timesheet->getProject()->getCustomer()->getCurrency();
$exported = $timesheet->isExported() ? 'entryState.exported' : 'entryState.not_exported';
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedDateTime($timesheet->getBegin()));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedDateTime($timesheet->getEnd()));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getUsername($timesheet));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getProject()->getCustomer()->getName());
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getProject()->getName());
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getActivity()->getName());
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getDescription());
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->translator->trans($exported));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, implode(',', $timesheet->getTagsAsArray()));
$this->setFormattedDate($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getBegin());
$entryHeaderColumn++;
$this->setFormattedTime($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getBegin());
$entryHeaderColumn++;
$this->setFormattedTime($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getEnd());
$entryHeaderColumn++;
$this->setDuration($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getDuration());
$durationColumn = $entryHeaderColumn;
$entryHeaderColumn++;
$this->setRate($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getRate(), $customerCurrency);
$rateColumn = $entryHeaderColumn;
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $this->getUsername($timesheet));
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getProject()->getCustomer()->getName());
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getProject()->getName());
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getActivity()->getName());
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getDescription());
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $this->translator->trans($exported));
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, implode(',', $timesheet->getTagsAsArray()));
$entryHeaderColumn++;
$this->setRate($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getHourlyRate(), $customerCurrency);
$entryHeaderColumn++;
$this->setRate($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getFixedRate(), $customerCurrency);
$entryHeaderColumn++;
foreach ($publicMetaFields as $metaFieldName) {
$metaField = $timesheet->getMetaField($metaFieldName);
$metaFieldValue = '';
@@ -169,28 +213,40 @@ abstract class AbstractSpreadsheetRenderer
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue);
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedMoney($timesheet->getHourlyRate(), $customerCurrency));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedMoney($timesheet->getFixedRate(), $customerCurrency));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedDuration($timesheet->getDuration()));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $this->getFormattedMoney($timesheet->getRate(), $customerCurrency));
$entryHeaderRow++;
}
$cellDurationTotal = $recordsHeaderColumn - 1;
$cellRateTotal = $recordsHeaderColumn;
if (null !== $durationColumn) {
$startCoordinate = $sheet->getCellByColumnAndRow($durationColumn, 2)->getCoordinate();
$endCoordinate = $sheet->getCellByColumnAndRow($durationColumn, $entryHeaderRow - 1)->getCoordinate();
$this->setDurationTotal($sheet, $durationColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyleByColumnAndRow($durationColumn, $entryHeaderRow);
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
}
$sheet->setCellValueByColumnAndRow($cellDurationTotal, $entryHeaderRow, $this->getFormattedDuration($durationTotal));
$sheet->setCellValueByColumnAndRow($cellRateTotal, $entryHeaderRow, $this->getFormattedMoney($rateTotal, $currency));
$sheet->getCellByColumnAndRow($cellDurationTotal, $entryHeaderRow)->getStyle()->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$sheet->getCellByColumnAndRow($cellDurationTotal, $entryHeaderRow)->getStyle()->getFont()->setBold(true);
$sheet->getCellByColumnAndRow($cellRateTotal, $entryHeaderRow)->getStyle()->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$sheet->getCellByColumnAndRow($cellRateTotal, $entryHeaderRow)->getStyle()->getFont()->setBold(true);
if (null !== $rateColumn) {
$startCoordinate = $sheet->getCellByColumnAndRow($rateColumn, 2)->getCoordinate();
$endCoordinate = $sheet->getCellByColumnAndRow($rateColumn, $entryHeaderRow - 1)->getCoordinate();
$this->setRateTotal($sheet, $rateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyleByColumnAndRow($rateColumn, $entryHeaderRow);
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
}
return $spreadsheet;
}
protected function getUsername(Timesheet $timesheet): string
{
if (!empty($timesheet->getUser()->getAlias())) {
return $timesheet->getUser()->getAlias();
}
return $timesheet->getUser()->getUsername();
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query

View File

@@ -12,6 +12,7 @@ namespace App\Export\Renderer;
use App\Export\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
final class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{
@@ -72,4 +73,9 @@ final class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererI
{
return 'csv';
}
protected function setDuration(Worksheet $sheet, $column, $row, $duration)
{
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=%s', $duration));
}
}

View File

@@ -1,75 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Renderer;
use App\Export\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
final class OdsRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{
/**
* @return string
*/
public function getFileExtension(): string
{
return '.ods';
}
/**
* @return string
*/
protected function getContentType(): string
{
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
}
/**
* @param Spreadsheet $spreadsheet
* @return string
* @throws \Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-export-ods');
if (false === $filename) {
throw new \Exception('Could not open temporary file');
}
$writer = IOFactory::createWriter($spreadsheet, 'Ods');
$writer->save($filename);
return $filename;
}
/**
* @return string
*/
public function getId(): string
{
return 'ods';
}
/**
* @return string
*/
public function getIcon(): string
{
return 'ods';
}
/**
* @return string
*/
public function getTitle(): string
{
return 'ods';
}
}

View File

@@ -67,8 +67,11 @@ class ActivityEditForm extends AbstractType
if ($options['customer']) {
$builder
->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($customer) {
return $repo->getQueryBuilderForFormType(new CustomerFormTypeQuery($customer));
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
'data' => $customer ? $customer : null,
'required' => false,
@@ -80,15 +83,18 @@ class ActivityEditForm extends AbstractType
$builder
->add('project', ProjectType::class, [
'required' => false,
'query_builder' => function (ProjectRepository $repo) use ($project, $customer) {
return $repo->getQueryBuilderForFormType(new ProjectFormTypeQuery($project, $customer));
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
]);
// replaces the project select after submission, to make sure only projects for the selected customer are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($project) {
function (FormEvent $event) use ($builder, $project) {
$data = $event->getData();
if (!isset($data['customer']) || empty($data['customer'])) {
return;
@@ -96,8 +102,11 @@ class ActivityEditForm extends AbstractType
$event->getForm()->add('project', ProjectType::class, [
'group_by' => null,
'query_builder' => function (ProjectRepository $repo) use ($data, $project) {
return $repo->getQueryBuilderForFormType(new ProjectFormTypeQuery($project, $data['customer']));
'query_builder' => function (ProjectRepository $repo) use ($builder, $data, $project) {
$query = new ProjectFormTypeQuery($project, $data['customer']);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
]);
}

View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\Customer;
use App\Form\Type\TeamType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CustomerTeamPermissionForm extends AbstractType
{
use EntityFormTrait;
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('teams', TeamType::class, [
'multiple' => true,
'expanded' => true,
'by_reference' => false,
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Customer::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'admin_customer_teams_edit',
'attr' => [
'data-form-event' => 'kimai.customerTeamUpdate'
],
]);
}
}

View File

@@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Extension;
use App\Entity\User;
use App\Security\CurrentUser;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class UserExtension extends AbstractTypeExtension
{
/**
* @var CurrentUser
*/
private $user;
public function __construct(CurrentUser $user)
{
$this->user = $user;
}
public static function getExtendedTypes(): iterable
{
return [FormType::class];
}
/**
* @param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefined(['user']);
// null needs to be allowed, as there is no user for anonymoud forms (like "forgot password" and "registration")
$resolver->setAllowedTypes('user', [User::class, 'null']);
$resolver->setDefault('user', $this->user->getUser());
}
}

View File

@@ -59,8 +59,11 @@ class ProjectEditForm extends AbstractType
'required' => false,
])
->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($customer) {
return $repo->getQueryBuilderForFormType(new CustomerFormTypeQuery($customer));
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
]);

View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\Project;
use App\Form\Type\TeamType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProjectTeamPermissionForm extends AbstractType
{
use EntityFormTrait;
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('teams', TeamType::class, [
'multiple' => true,
'expanded' => true,
'by_reference' => false,
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Project::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'admin_project_teams_edit',
'attr' => [
'data-form-event' => 'kimai.projectTeamUpdate'
],
]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\Team;
use App\Form\Type\CustomerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TeamCustomerForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('customers', CustomerType::class, [
'multiple' => true,
'expanded' => true,
'by_reference' => false,
'query_builder_for_user' => false,
'required' => false,
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Team::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'admin_team_customer',
'attr' => [
'data-form-event' => 'kimai.teamUpdate'
],
]);
}
}

61
src/Form/TeamEditForm.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\Team;
use App\Form\Type\UserType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TeamEditForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name', TextType::class, [
'label' => 'label.name',
'attr' => [
'autofocus' => 'autofocus'
],
])
->add('teamlead', UserType::class, [
'label' => 'label.teamlead',
'multiple' => false,
'expanded' => false,
])
->add('users', UserType::class, [
'multiple' => true,
'expanded' => true,
'by_reference' => false,
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Team::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'admin_team_edit',
'attr' => [
'data-form-event' => 'kimai.teamUpdate'
],
]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form;
use App\Entity\Team;
use App\Form\Type\ProjectType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TeamProjectForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('projects', ProjectType::class, [
'multiple' => true,
'expanded' => false,
'by_reference' => false,
'attr' => ['size' => '20'],
'query_builder_for_user' => false,
'required' => false,
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Team::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'admin_team_project',
'attr' => [
'data-form-event' => 'kimai.teamUpdate'
],
]);
}
}

View File

@@ -163,8 +163,11 @@ class TimesheetEditForm extends AbstractType
{
$builder
->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($customer) {
return $repo->getQueryBuilderForFormType(new CustomerFormTypeQuery($customer));
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
'data' => $customer ? $customer : '',
'required' => false,
@@ -189,8 +192,11 @@ class TimesheetEditForm extends AbstractType
array_merge($projectOptions, [
'placeholder' => '',
'activity_enabled' => true,
'query_builder' => function (ProjectRepository $repo) use ($project, $customer) {
return $repo->getQueryBuilderForFormType(new ProjectFormTypeQuery($project, $customer));
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
])
);
@@ -198,7 +204,7 @@ class TimesheetEditForm extends AbstractType
// replaces the project select after submission, to make sure only projects for the selected customer are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($project, $customer, $isNew) {
function (FormEvent $event) use ($builder, $project, $customer, $isNew) {
$data = $event->getData();
$customer = isset($data['customer']) && !empty($data['customer']) ? $data['customer'] : null;
$project = isset($data['project']) && !empty($data['project']) ? $data['project'] : $project;
@@ -207,7 +213,7 @@ class TimesheetEditForm extends AbstractType
'placeholder' => '',
'activity_enabled' => true,
'group_by' => null,
'query_builder' => function (ProjectRepository $repo) use ($project, $customer, $isNew) {
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer, $isNew) {
// is there a better wa to prevent starting a record with a hidden project ?
if ($isNew && !is_object($project)) {
/** @var Project $project */
@@ -221,8 +227,10 @@ class TimesheetEditForm extends AbstractType
}
}
}
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType(new ProjectFormTypeQuery($project, $customer));
return $repo->getQueryBuilderForFormType($query);
},
]);
}

View File

@@ -24,7 +24,9 @@ use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\ProjectFormTypeQuery;
use App\Repository\Query\TimesheetQuery;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
@@ -48,9 +50,6 @@ abstract class AbstractToolbarForm extends AbstractType
return '';
}
/**
* @param FormBuilderInterface $builder
*/
protected function addUserChoice(FormBuilderInterface $builder)
{
$builder->add('user', UserType::class, [
@@ -59,20 +58,30 @@ abstract class AbstractToolbarForm extends AbstractType
]);
}
protected function addCustomerChoice(FormBuilderInterface $builder)
protected function addUsersChoice(FormBuilderInterface $builder)
{
$builder->add('users', UserType::class, [
'label' => 'label.user',
'multiple' => true,
'required' => false,
]);
}
protected function addCustomerChoice(FormBuilderInterface $builder, bool $required = false)
{
// just a fake field for having this field at the right position in the frontend
$builder->add('customer', HiddenType::class);
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) {
function (FormEvent $event) use ($builder, $required) {
$data = $event->getData();
$event->getForm()->add('customer', CustomerType::class, [
'required' => false,
'required' => $required,
'project_enabled' => true,
'query_builder' => function (CustomerRepository $repo) use ($data) {
'query_builder' => function (CustomerRepository $repo) use ($builder, $data) {
$query = new CustomerFormTypeQuery();
$query->setUser($builder->getOption('user'));
if (isset($data['customer']) && !empty($data['customer'])) {
$query->setCustomer($data['customer']);
}
@@ -84,10 +93,6 @@ abstract class AbstractToolbarForm extends AbstractType
);
}
/**
* @param FormBuilderInterface $builder
* @param string $label
*/
protected function addVisibilityChoice(FormBuilderInterface $builder, string $label = 'label.visible')
{
$builder->add('visibility', VisibilityType::class, [
@@ -97,9 +102,6 @@ abstract class AbstractToolbarForm extends AbstractType
]);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addPageSizeChoice(FormBuilderInterface $builder)
{
$builder->add('pageSize', PageSizeType::class, [
@@ -107,9 +109,6 @@ abstract class AbstractToolbarForm extends AbstractType
]);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addUserRoleChoice(FormBuilderInterface $builder)
{
$builder->add('role', UserRoleType::class, [
@@ -117,9 +116,6 @@ abstract class AbstractToolbarForm extends AbstractType
]);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addDateRangeChoice(FormBuilderInterface $builder, $allowEmpty = true)
{
$builder->add('daterange', DateRangeType::class, [
@@ -128,9 +124,6 @@ abstract class AbstractToolbarForm extends AbstractType
]);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addProjectChoice(FormBuilderInterface $builder)
{
// just a fake field for having this field at the right position in the frontend
@@ -138,13 +131,14 @@ abstract class AbstractToolbarForm extends AbstractType
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) {
function (FormEvent $event) use ($builder) {
$data = $event->getData();
$event->getForm()->add('project', ProjectType::class, [
'required' => false,
'activity_enabled' => true,
'query_builder' => function (ProjectRepository $repo) use ($data) {
'query_builder' => function (ProjectRepository $repo) use ($builder, $data) {
$query = new ProjectFormTypeQuery();
$query->setUser($builder->getOption('user'));
if (isset($data['customer']) && !empty($data['customer'])) {
$query->setCustomer($data['customer']);
@@ -160,9 +154,6 @@ abstract class AbstractToolbarForm extends AbstractType
);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addActivityChoice(FormBuilderInterface $builder)
{
// just a fake field for having this field at the right position in the frontend
@@ -191,9 +182,6 @@ abstract class AbstractToolbarForm extends AbstractType
);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addHiddenPagination(FormBuilderInterface $builder)
{
$builder->add('page', HiddenType::class, [
@@ -201,13 +189,38 @@ abstract class AbstractToolbarForm extends AbstractType
]);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addTagInputField(FormBuilderInterface $builder)
{
$builder->add('tags', TagsInputType::class, [
'required' => false
]);
}
protected function addTimesheetStateChoice(FormBuilderInterface $builder)
{
$builder->add('state', ChoiceType::class, [
'label' => 'label.entryState',
'required' => false,
'placeholder' => null,
'choices' => [
'entryState.all' => TimesheetQuery::STATE_ALL,
'entryState.running' => TimesheetQuery::STATE_RUNNING,
'entryState.stopped' => TimesheetQuery::STATE_STOPPED
],
]);
}
protected function addExportStateChoice(FormBuilderInterface $builder)
{
$builder->add('exported', ChoiceType::class, [
'label' => 'label.exported',
'required' => false,
'placeholder' => null,
'choices' => [
'entryState.all' => TimesheetQuery::STATE_ALL,
'entryState.exported' => TimesheetQuery::STATE_EXPORTED,
'entryState.not_exported' => TimesheetQuery::STATE_NOT_EXPORTED
],
]);
}
}

Some files were not shown because too many files have changed in this diff Show More