Compare commits

...

91 Commits

Author SHA1 Message Date
Kevin Papst
c8098b2e00 Release 1.19.7 (#3286)
* fix isWeekend() test for sunday fdow
* re-use the pattern for optgroup title
* prevent method on null
* composer update
* pre-select an option if it is the only available one
* added command to stop all active timesheets
2022-05-07 23:47:32 +02:00
Kevin Papst
100f8a5165 Release 1.19.6 (#3277) 2022-04-29 18:14:12 +02:00
Kevin Papst
76899d2fdb Release 1.19.5 (#3265) 2022-04-26 18:48:43 +02:00
j0hannesr0th
41e8e196ec fix: billable not respected in api post request (#3275) 2022-04-26 17:46:44 +02:00
Kevin Papst
ef5977bcfe allow changing calendar entry title (#3272) 2022-04-25 18:18:48 +02:00
Weblate (bot)
e94d47bf0f Translations update from Hosted Weblate (#3267)
Co-authored-by: SHINJI.K <shinkuroshi@gmail.com>
Co-authored-by: Tamás Papp <tamas.papp.it@gmail.com>
Co-authored-by: xiexieqing <xqmayone@outlook.com>
2022-04-25 16:59:55 +02:00
Weblate (bot)
f8bbb3065c Translated using Weblate (#3263)
Co-authored-by: Nathan <bonnemainsnathan@gmail.com>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: Wellington Terumi Uemura <wellingtonuemura@gmail.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: adam Petro <grimnoobster@gmail.com>
Co-authored-by: dkstiler <dkstiler@gmail.com>
2022-04-19 13:12:44 +02:00
Kevin Papst
bc908b4534 Release 1.19.4 (#3255)
* login redirects to homepage if already being logged-in
* fix budget check for entries that were moved to another moth
* invoice: fix amount should be decimal if decimal template is used
* added new month grouped by project/activity/user report
2022-04-18 12:41:45 +02:00
Kevin Papst
a179719c3e version 1.19.3 (#3250) 2022-04-05 18:10:09 +02:00
Kevin Papst
2ca3a3f7db interface for saml config (#3249) 2022-04-05 17:49:41 +02:00
Kevin Papst
f2a8cf0930 Release 1.19.2 (#3244) 2022-04-05 00:59:13 +02:00
Weblate (bot)
966701f94e Translated using Weblate (#3235)
Co-authored-by: ANIGO R. (AR) <franigoyt@gmail.com>
Co-authored-by: leuedaniel <leuenberger.daniel@bluewin.ch>
2022-04-04 18:20:55 +02:00
Kevin Papst
1a183a4825 allow arbitrary string length for system configurations (#3243) 2022-04-04 17:38:50 +02:00
Kevin Papst
3a7e3b2168 bump version 1.19.1 (#3229) 2022-03-30 22:23:12 +02:00
Kevin Papst
eb2664ac79 support more complex metafield queries (#3228) 2022-03-30 19:01:22 +02:00
Kevin Papst
31743cf962 fix billable calculation on timesheet restart (#3225) 2022-03-29 23:24:22 +02:00
Kevin Papst
2c2ba2e32c show user account number in report export (#3224) 2022-03-29 12:48:42 +02:00
Weblate (bot)
4ff2153e15 Translated using Weblate (#3221)
Co-authored-by: ButterflyOfFire <ButterflyOfFire@protonmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Mzw <466271552@qq.com>
2022-03-29 12:31:35 +02:00
Kevin Papst
3c6b5c6c1e fix relative times in budget calculation in export (#3216) 2022-03-22 21:02:29 +01:00
Kevin Papst
6c1d79fe5b release 1.19 (#3215) 2022-03-21 23:42:56 +01:00
Weblate (bot)
e5f1df8c70 Translated using Weblate (#3208)
Co-authored-by: Gil <almontegil@gmail.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: Taplou <ploutarchosgram@gmail.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: 이정희 <daemul72@gmail.com>
2022-03-21 21:32:46 +01:00
Kevin Papst
d6b59f6799 defensive javascript (#3210) 2022-03-20 21:08:59 +01:00
Kevin Papst
4b069edd33 added event to extend detail pages (#3209) 2022-03-19 23:54:40 +01:00
Kevin Papst
a8ac6b2eda Release 1.18.3 (#3204)
* fix truncated comments for customer, project and activity
* fix export button label for non-translated renderer
* fixed avatar size if image or SVG is used
* filter timesheets by billable state in API
2022-03-18 23:45:41 +01:00
Weblate (bot)
5c925f0ca5 Translated using Weblate (#3194)
Co-authored-by: Kevin Papst <kevin@kevinpapst.de>
Co-authored-by: Simona Iacob <s@zp1.net>
Co-authored-by: Taplou <ploutarchosgram@gmail.com>
2022-03-18 23:25:22 +01:00
Kevin Papst
30c7782d6e automatic billable calculation (#3200) 2022-03-18 22:31:50 +01:00
Weblate (bot)
f7480902b4 Translated using Weblate (#3181)
Co-authored-by: ButterflyOfFire <ButterflyOfFire@protonmail.com>
Co-authored-by: Eric <alchemillatruth@purelymail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Simona Iacob <s@zp1.net>
Co-authored-by: Tiger <tigercrl@icloud.com>
2022-03-11 23:50:37 +01:00
Kevin Papst
00b5a65859 1.18.2 (#3190)
* improve console version output
* fix empty string issue in csv export (DDE protection) - fixes #3189
* fix twig sort deprecation in php 8
* sort user by displayName in users report
* fix missing custom translations in edit modal
* fix font-family in invoice.css
* invoice template "freelancer pdf" - added customer number, moved some fields around
* invoice template "default" - relocate customer number and order number in
2022-03-11 23:25:23 +01:00
Kevin Papst
317ac59f47 fix select2 and dropdown width for quick-entry form (#3188) 2022-03-07 14:22:16 +01:00
Kevin Papst
b08c156552 1.18.1 - fix description pattern in title (#3179)
* bump version
* add comment to customer/project/activity entity and collections api 
* improve title pattern usage
2022-02-28 16:32:41 +01:00
Weblate (bot)
2ad7629c2a Translations update from Hosted Weblate (#3177)
* fix broken files containing keys from messages namespace
* pre-fill missing translations with english
* new command to pre-fill empty translations
* fix locale in filename
* activate locales fa and nb_NO

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Eric <alchemillatruth@purelymail.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Wellington Terumi Uemura <wellingtonuemura@gmail.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: Kevin Papst <kpapst@gmx.net>
2022-02-28 16:02:40 +01:00
Kevin Papst
42963ead5c switch to excel cell formats for report exports (#3178) 2022-02-27 13:36:52 +01:00
Kevin Papst
c0168ecc5b 1.18 (#3158)
* bump version
* fix translation ids
* added css classes to modify form with custom css
* improve export pdf file names
* respect financial year in new report
* added new InvoiceCalculator: price
* upgrade packages and node-sass to v7
* title pattern for customer, project and activity via API
* support negative money without currency
* fix sub-locale in print export template
* fix overbooking validation for monthly budget
* fix copying entities with different set of custom-fields compared to the current configuration
2022-02-25 20:41:56 +01:00
Weblate (bot)
34649d809e Translated using Weblate (Croatian) (#3174)
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
2022-02-25 20:40:16 +01:00
Weblate (bot)
ee0f6b0ccf Translations update from Hosted Weblate (#3171)
Co-authored-by: SC <lalocas@protonmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
2022-02-25 15:30:37 +01:00
Kevin Papst
c8b47d1033 added explicit classes for widgets (#3170) 2022-02-22 21:15:19 +01:00
Weblate (bot)
3d3ae97af4 Translations update from Hosted Weblate (#3160)
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: Wellington Terumi Uemura <wellingtonuemura@gmail.com>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: dkstiler <dkstiler@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
2022-02-22 13:29:48 +01:00
fredyb
d7066a2bbc support subtotal in filtered spreadsheet (#3166) 2022-02-22 13:29:03 +01:00
Weblate (bot)
7f8545f1dc Translations update from Hosted Weblate (#3105)
Co-authored-by: Miovio <mgrundemar@gmail.com>
Co-authored-by: ButterflyOfFire <ButterflyOfFire@protonmail.com>
Co-authored-by: SC <lalocas@protonmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Kevin Papst <kpapst@gmx.net>
2022-02-16 12:57:47 +01:00
Kevin Papst
85a63c4b78 new user-year reporting and data-type chooser (#3155) 2022-02-16 12:18:04 +01:00
Kevin Papst
1852d71974 export user-list reports in excel (#3154) 2022-02-14 17:55:10 +01:00
Kevin Papst
7da7468965 configure display of customer, project and activity in dropdown lists (#3151) 2022-02-14 17:43:05 +01:00
Kevin Papst
83a894823f release 1.17.2 (#3135)
* allow to input password interactively in console
* added block to simplify overwriting export template parts
* prevent installation in PHP 8.1
* composer update
* phpunit version to 9
* support negative amounts in excel export
* added system configuration actions for calendar, weekly timesheet, users
* added method to render text with full markdown support
2022-02-14 17:33:52 +01:00
Kevin Papst
4cb0ac37a6 unify access to custom fields (#3106) 2022-01-28 00:04:58 +01:00
Weblate (bot)
7a6fab8624 Translations update from Hosted Weblate (#3093)
Co-authored-by: Simona Iacob <s@zp1.net>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: Eric <alchemillatruth@purelymail.com>
Co-authored-by: Bogi Napoleon Wennerstrøm <bogi.wennerstrom@gmail.com>
Co-authored-by: Oliver Darvishi <nimawm81@gmail.com>
Co-authored-by: Wellington Terumi Uemura <wellingtonuemura@gmail.com>
2022-01-25 14:07:30 +01:00
Kevin Papst
6416b67c63 fix admin access for customer in invoice module (#3095) 2022-01-25 14:06:09 +01:00
Kevin Papst
1e269a6f82 improve team member handling (#3097) 2022-01-25 13:44:49 +01:00
Kevin Papst
8694a3bfe4 added invoice delete event (#3096) 2022-01-24 21:33:13 +01:00
Kevin Papst
9e320674c1 phpstan improvements (#3092) 2022-01-23 15:49:38 +01:00
Kevin Papst
9047e9e785 optimize prod error messages (#3091) 2022-01-23 15:29:41 +01:00
Kevin Papst
b8d8f0c16d release 1.17 (#3090) 2022-01-23 01:33:00 +01:00
Kevin Papst
8b8e9b001e bump dependencies (#3089) 2022-01-22 17:11:30 +01:00
Weblate (bot)
9c40bd98aa Translations update from Hosted Weblate (#3048)
Co-authored-by: ssantos <ssantos@web.de>
Co-authored-by: 黄煜祺 <QQBB18646265284@163.com>
Co-authored-by: Hibiki Kimura <h.kimura787@gmail.com>
Co-authored-by: 좀비때려잡아 <mickey9301@gmail.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: Kevin Papst <kpapst@gmx.net>
2022-01-22 13:38:45 +01:00
Kevin Papst
1a0608f49e new export template (#3082) 2022-01-22 13:20:22 +01:00
Kevin Papst
3543e0a1b5 code improvements (#3088) 2022-01-22 02:46:02 +01:00
Kevin Papst
97c23f75dd improve permission handling for quick entry controller (#3081) 2022-01-20 22:24:19 +01:00
Kevin Papst
49bb9980b8 added invoice model to invoice created event (#3079)
* added model to InvoiceCreatedEvent
* allow to switch formatter locale
2022-01-20 18:41:55 +01:00
Kevin Papst
fd4cbb43c1 bugfixes (#3078)
* fix phpdoc
* fix invoice export field order
* prevent empty migration warning
* do not trigger export validation on new timesheets
* update license year
* remove trailing comma in function call for php compatibility
* add new composer plugin config
* bump phpunit schema version
2022-01-20 17:10:56 +01:00
Kevin Papst
40242b7dcc support custom fields for invoices (#3077) 2022-01-20 16:59:26 +01:00
Kevin Papst
9731023014 1.16.10 (#3047) 2022-01-01 18:12:57 +01:00
Weblate (bot)
0abbba7a56 Translations update from Hosted Weblate (#3032)
Co-authored-by: easyjoh <johannes@easyplusplus.net>
Co-authored-by: Dmitry <dmitrydmitry761@gmail.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: Wellington Terumi Uemura <wellingtonuemura@gmail.com>
Co-authored-by: Simona Iacob <s@zp1.net>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: Eric <spice2wolf@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: 拉姆徐 <xulong_715138082@qq.com>
Co-authored-by: Michal Gajewski <gajewmic@gmail.com>
Co-authored-by: DJScias <djscias@gmail.com>
2021-12-30 22:53:01 +01:00
Kevin Papst
cc809183ae added comment field to invoice (#3045) 2021-12-30 21:49:30 +01:00
Kevin Papst
f7b3f4ed76 improve export permission checks (#3027) 2021-12-17 01:10:49 +01:00
Kevin Papst
04fc954769 allow to configure the amount of recent activity rows in an empty week (#3026) 2021-12-16 16:58:10 +01:00
Kevin Papst
3621b8c27c fix invoice budget calculation (#3024) 2021-12-15 17:56:18 +01:00
Kevin Papst
358959522d release 1.16.9 (#3018)
* fix filter action display
* allow to set system configuration during runtime
* bump version
* use deepl pro free to translate missing keys
* replacing broken Github action
2021-12-14 02:29:06 +01:00
Weblate (bot)
19cd5962aa Translations update from Hosted Weblate (#3013)
Co-authored-by: Trần Công Minh <minhhp198x@gmail.com>
2021-12-14 00:10:26 +01:00
Kevin Papst
961336f35c fix project are not filtered after submit (#3016) 2021-12-11 15:51:23 +01:00
Kevin Papst
573fa9f7f3 fix create invoice token issue (#3007) 2021-12-09 01:36:58 +01:00
Kevin Papst
22ce6b047a new invoice template variables for budgets (#3005) 2021-12-08 22:15:07 +01:00
Kevin Papst
82525f382d fix invoice preview (#3002) 2021-12-08 14:21:15 +01:00
Kevin Papst
ec9dd08970 cleanup duplicate translation ids (#3001) 2021-12-08 01:49:44 +01:00
Kevin Papst
9ec0280334 improve translation test and fix error (#2998)
Co-authored-by: Kristoffer Grundström <swedishsailfishosuser@tutanota.com>
2021-12-07 16:56:08 +01:00
Kevin Papst
0861dbd75d new command to work with translation files (#2993) 2021-12-05 14:54:02 +01:00
Kevin Papst
f5949b6b9a use new trustedAuthors flag in CI action (#2992) 2021-12-04 22:46:39 +01:00
Weblate (bot)
99905497e1 Translations update from Hosted Weblate (#2951)
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: Wellington Terumi Uemura <wellingtonuemura@gmail.com>
Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Co-authored-by: SC <lalocas@protonmail.com>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: dkstiler <dkstiler@gmail.com>
Co-authored-by: BeckeBauer <berko@gmx.de>
Co-authored-by: Bogi Napoleon Wennerstrøm <bogi.wennerstrom@gmail.com>
Co-authored-by: Miovio <mgrundemar@gmail.com>
Co-authored-by: Roman Ondráček <ondracek.roman@centrum.cz>
Co-authored-by: Dronrs <Dutythinking@protonmail.com>
Co-authored-by: Michal Matúšov <kubof.hromoslav@gmail.com>
Co-authored-by: Simona Iacob <s@zp1.net>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Kevin Papst <kpapst@gmx.net>
2021-12-04 02:37:04 +01:00
Kevin Papst
4ae18b73d1 bump version (#2991) 2021-12-04 02:27:32 +01:00
Kevin Papst
1da26e041d fix invoice create and search (#2990) 2021-12-04 01:18:49 +01:00
Kevin Papst
4e42911f3d more csrf protection for invoice and search (#2984) 2021-12-02 18:01:51 +01:00
Kevin Papst
b0045a910c prevent that lock files will be committed in PRs (#2983) 2021-12-02 13:06:23 +01:00
Kevin Papst
9491f07ade fix deleting invoice documents (#2980) 2021-11-29 15:57:39 +01:00
Kevin Papst
d994339de5 fix two opening form tags (#2972) 2021-11-29 15:49:54 +01:00
Kevin Papst
0464054a2b bump version 2021-11-24 12:55:38 +01:00
Kevin Papst
b062164277 allow to delete invoice documents (#2968) 2021-11-24 10:30:49 +01:00
Kevin Papst
ff9acab0fc improve permissison handling in invoice screen (#2965) 2021-11-21 16:41:03 +01:00
Kevin Papst
76e09447c8 make sure that markdown uses safe mode (#2961) 2021-11-19 23:07:27 +01:00
Kevin Papst
9470699798 escape data in calendar popover (#2960) 2021-11-19 22:57:03 +01:00
Kevin Papst
89bfa82c61 escape customer, project and activity name in javascript (#2959) 2021-11-19 19:45:37 +01:00
Kevin Papst
859131308a improve export filename (#2958) 2021-11-19 18:53:43 +01:00
tdozbun-reno
56d02673ed Update CSRF Token IDs for Issue kevinpapst/kimai2#2947 (#2948)
* bump version
* removed not needed token, as it is already contained in the form
Co-authored-by: Kevin Papst <kpapst@gmx.net>
2021-11-18 18:32:00 +01:00
Kevin Papst
b28e9c120c version 1.16.2 (#2942)
* bump version
* include calendar week in week chooser
* table names in SQL
* show save flash message
* prevent migration warning
* drop default value to prevent error when server version is not set
* csrf token for duplicate actions
* updated translations
2021-11-18 12:33:13 +01:00
755 changed files with 25666 additions and 7333 deletions

View File

@@ -19,20 +19,40 @@ jobs:
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
strategy:
matrix:
php: ['7.4']
php: ['8.0']
name: Coverage (${{ matrix.php }})
steps:
- uses: actions/checkout@v2
- uses: shivammathur/setup-php@v2
- name: Clone Kimai
uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: pcov
extensions: mbstring, xml, ctype, iconv, intl, mysql, zip, gd, ldap
- uses: ramsey/composer-install@v1
- run: composer require laminas/laminas-ldap
- name: Determine composer cache directory
id: composer-cache
run: "echo \"::set-output name=directory::$(composer config cache-dir)\""
- name: Cache Composer dependencies
uses: actions/cache@v2
with:
path: "${{ steps.composer-cache.outputs.directory }}"
key: ${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
- name: Install dependencies
run: composer install
- name: Install LDAP package
run: composer require laminas/laminas-ldap
- name: Setup problem matchers for PHPUnit
run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
- name: Run tests
run: vendor/bin/phpunit tests/ --coverage-clover=coverage.xml
env:
@@ -40,6 +60,7 @@ jobs:
APP_ENV: dev
MAILER_URL: null://localhost
TEST_WITH_BUNDLES: 1
- name: Upload code coverage
uses: codecov/codecov-action@v2
with:

21
.github/workflows/lockfiles.yaml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: Check .lock files
on:
pull_request: null
push:
branches:
- master
jobs:
lockfiles:
runs-on: ubuntu-latest
name: Verify lock file integrity
steps:
- name: Clone Kimai
uses: actions/checkout@v2
- name: Prevent file change
uses: xalvarez/prevent-file-change-action@v1
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
pattern: .*.lock
trustedAuthors: kevinpapst, dependabot

View File

@@ -19,7 +19,7 @@ jobs:
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
strategy:
matrix:
php: ['7.3', '7.4', '8.0']
php: ['7.4', '8.0', '8.1']
name: Integration (${{ matrix.php }})
steps:
@@ -33,14 +33,27 @@ jobs:
php-version: ${{ matrix.php }}
coverage: none
extensions: mbstring, xml, ctype, iconv, intl, mysql, zip, gd, ldap
tools: cs2pr:1.1.0
tools: cs2pr:1.1.0, symfony-cli
- name: Determine composer cache directory
id: composer-cache
run: "echo \"::set-output name=directory::$(composer config cache-dir)\""
- name: Cache Composer dependencies
uses: actions/cache@v2
with:
path: "${{ steps.composer-cache.outputs.directory }}"
key: ${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
- name: Install dependencies
uses: ramsey/composer-install@v1
run: composer install
- name: Validate Composer
run: composer validate --strict
- name: Warmup cache for PHPStan
run: APP_ENV=dev bin/console kimai:reload -n
- name: Check codestyles
run: vendor/bin/php-cs-fixer fix --dry-run --verbose --config=.php-cs-fixer.dist.php --using-cache=no --show-progress=none --format=checkstyle | cs2pr
@@ -53,6 +66,9 @@ jobs:
- name: Lint codebase
run: composer linting
- name: Check for security issues in packages
run: symfony security:check
- name: Install LDAP package
run: composer require laminas/laminas-ldap

View File

@@ -1,5 +1,5 @@
unreleased=true
future-release=1.16
future-release=1.19
exclude-labels=duplicate,support,question,invalid,wontfix,release,waiting for feedback,documentation
enhancement_labels=>enhancement,Enhancement,feature request,translation i18n,technical debt,documentation
issues-wo-labels=false

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2017-2019 Kevin Papst @ https://www.kevinpapst.de
Copyright (c) 2017-2022 Kevin Papst @ https://www.kevinpapst.de
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -27,19 +27,20 @@ It is built with modern technologies such as [Symfony](https://github.com/symfon
### Requirements
- PHP 7.3 or higher (PHP 8 supported)
- PHP 7.4, 8.0 or 8.1
- MariaDB or MySQL
- A webserver and subdomain
- PHP extensions: `gd`, `intl`, `json`, `mbstring`, `pdo`, `xsl`, `zip`
### About
This is the new version of the open source time tracker Kimai. It is stable and production ready, ships
The evolution of the most known(?) open source project time-tracker Kimai. It is stable, production ready and ships
with many advanced features, including but not limited to:
JSON API, invoicing, data exports, multi-timer and punch-in punch-out mode, tagging, multi-user and multi-timezones,
authentication via SAML/LDAP/Database, customizable role and team permissions, responsive and ready for your mobile device,
user specific rates, advanced search & filtering, money and time budgets, reporting, support for plugins and many more.
user/customer/project specific rates, advanced search & filtering, money and time budgets, reporting, support for plugins
and so many more.
## Installation
@@ -63,9 +64,9 @@ user specific rates, advanced search & filtering, money and time budgets, report
You can see a rough development roadmap in the [Milestones](https://github.com/kevinpapst/kimai2/milestones) sections.
It is open for changes and input from the community, your [ideas and questions](https://github.com/kevinpapst/kimai2/issues) are welcome.
> Kimai 2 uses a rolling release concept for delivering updates.
> You can upgrade Kimai at any time, you don't need to wait for the next official release.
> The master branch is always deployable, release tags are only snapshots of the current development version.
> Kimai uses a rolling release concept for delivering updates.
> You don't have to wait for the next official release, upgrade it at any time from the master branch,
> which is always deployable - release tags are simple snapshots of the development version.
Release versions will be created on a regular base (approx. one release every 2 months).
Every code change, whether it's a new feature or a bugfix, will be done on the master branch.
@@ -78,11 +79,11 @@ The best way to start is to [open a new issue](https://github.com/kevinpapst/kim
In case you want to contribute, but you wouldn't know how, here are some suggestions:
- Spread the word: More user means more people testing and contributing to Kimai - which in turn means better stability and more and better features. Please vote for Kimai on platforms lie Slant, Product Hunt, Softpedia or AlternativeTo, you can tweet about it, share it on LinkedIn, reddit or any of your favorite social media platforms. Every little bit helps!
- Spread the word: More user means more people testing and contributing to Kimai - which in turn means better stability and more and better features. Please vote for Kimai on platforms like Slant, Product Hunt, Softpedia or AlternativeTo, you can tweet about it, share it on LinkedIn, reddit or any of your favorite social media platforms. Every bit helps!
- Answer questions: You know the answer to another user's problem? Share your knowledge!
- Make a feature request: Something can be done better? Something essential missing? Let us know!
- Report bugs
- Contribute: You don't have to be programmer to help. The documentation and translation could use some love as well.
- Sponsor the project
- You don't have to be programmer to help. The documentation and translation could use some love as well.
- Sponsor the project, free software still costs money
There is one simple rule in our "Code of conduct": Don't be an ass!

View File

@@ -5,10 +5,10 @@
As announced in the [README](README.md) I only support the latest available release and master.
| Version | Supported |
| ------- | ------------------ |
|---------|--------------------|
| master | :white_check_mark: |
| 1.16 | :white_check_mark: |
| < 1.16 | :x: |
| 1.19 | :white_check_mark: |
| < 1.19 | :x: |
## Reporting a Vulnerability
@@ -20,5 +20,5 @@ Depending on the size of the required fixes, this might take a couple of hours o
You can expect that your message will be answered ASAP.
If your issue is valid and after I verified and fixed it, you will be mentioned in the release notes.
You will be mentioned in the release notes if your issue is valid and after it was fixed.
I am grateful for any (discrete) disclosure of vulnerabilities!

View File

@@ -20,6 +20,7 @@ require('select2/dist/js/i18n/de');
require('select2/dist/js/i18n/el');
require('select2/dist/js/i18n/es');
require('select2/dist/js/i18n/eu');
require('select2/dist/js/i18n/fa');
require('select2/dist/js/i18n/fi');
require('select2/dist/js/i18n/fr');
require('select2/dist/js/i18n/he');
@@ -27,6 +28,7 @@ require('select2/dist/js/i18n/hu');
require('select2/dist/js/i18n/it');
require('select2/dist/js/i18n/ja');
require('select2/dist/js/i18n/ko');
require('select2/dist/js/i18n/nb');
require('select2/dist/js/i18n/nl');
require('select2/dist/js/i18n/pl');
require('select2/dist/js/i18n/pt');
@@ -51,6 +53,7 @@ require('moment/locale/el');
require('moment/locale/eo');
require('moment/locale/es');
require('moment/locale/eu');
require('moment/locale/fa');
require('moment/locale/fi');
require('moment/locale/fo');
require('moment/locale/fr');
@@ -59,6 +62,7 @@ require('moment/locale/hu');
require('moment/locale/it');
require('moment/locale/ja');
require('moment/locale/ko');
require('moment/locale/nb');
require('moment/locale/nl');
require('moment/locale/pl');
require('moment/locale/pt');

View File

@@ -14,6 +14,7 @@ require('fullcalendar/dist/locale/de-ch');
require('fullcalendar/dist/locale/el');
require('fullcalendar/dist/locale/es');
require('fullcalendar/dist/locale/eu');
require('fullcalendar/dist/locale/fa');
require('fullcalendar/dist/locale/fi');
require('fullcalendar/dist/locale/fr');
require('fullcalendar/dist/locale/he');
@@ -21,6 +22,7 @@ require('fullcalendar/dist/locale/hu');
require('fullcalendar/dist/locale/it');
require('fullcalendar/dist/locale/ja');
require('fullcalendar/dist/locale/ko');
require('fullcalendar/dist/locale/nb');
require('fullcalendar/dist/locale/nl');
require('fullcalendar/dist/locale/pl');
require('fullcalendar/dist/locale/pt');

View File

@@ -36,6 +36,7 @@ import KimaiDatePicker from "./plugins/KimaiDatePicker";
import KimaiConfirmationLink from "./plugins/KimaiConfirmationLink";
import KimaiMultiUpdateTable from "./plugins/KimaiMultiUpdateTable";
import KimaiDateUtils from "./plugins/KimaiDateUtils";
import KimaiEscape from "./plugins/KimaiEscape";
export default class KimaiLoader {
@@ -48,6 +49,7 @@ export default class KimaiLoader {
new KimaiTranslation(translations)
);
kimai.registerPlugin(new KimaiEscape());
kimai.registerPlugin(new KimaiEvent());
kimai.registerPlugin(new KimaiAPI());
kimai.registerPlugin(new KimaiAlert());

View File

@@ -42,7 +42,7 @@ export default class KimaiPlugin {
}
/**
* This function returns null, if xou call it BEFORE init().
* This function returns null, if you call it BEFORE init().
*
* @returns {KimaiContainer}
*/
@@ -66,4 +66,11 @@ export default class KimaiPlugin {
return this.getContainer().getPlugin(name);
}
/**
* @param {string} title
* @returns {string}
*/
escape(title) {
return this.getPlugin('escape').escapeForHtml(title);
};
}

View File

@@ -37,7 +37,11 @@ export default class KimaiActiveRecordsDuration extends KimaiPlugin {
if (activeRecords.length === 0) {
if (this.updateBrowserTitle) {
document.title = document.querySelector('body').dataset['title'];
if (document.body.dataset['title'] === undefined) {
this.updateBrowserTitle = false;
} else {
document.title = document.body.dataset['title'];
}
}
return;
}

View File

@@ -18,6 +18,14 @@ export default class KimaiDateUtils extends KimaiPlugin {
return 'date';
}
/**
* @param {string} dateTime
* @returns {string}
*/
getFormattedDate(dateTime) {
return moment(dateTime).format(this.getConfiguration('formatDate'));
}
getWeekDaysShort() {
return moment.localeData().weekdaysShort();
}

View File

@@ -0,0 +1,35 @@
/*
* 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.
*/
/*!
* [KIMAI] KimaiEscape: sanitize strings
*/
import KimaiPlugin from "../KimaiPlugin";
export default class KimaiEscape extends KimaiPlugin {
getId() {
return 'escape';
}
/**
* @param {string} title
* @returns {string}
*/
escapeForHtml(title) {
const tagsToReplace = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
};
return title.replace(/[&<>]/g, function(tag) {
return tagsToReplace[tag] || tag;
});
};
}

View File

@@ -33,46 +33,22 @@ export default class KimaiForm extends KimaiPlugin {
this.getContainer().getPlugin('date-range-picker').destroyDateRangePicker(formSelector);
}
getFormData(form) {
/**
* @param {HTMLFormElement} form
* @param {Object} overwrites
* @returns {string}
*/
convertFormDataToQueryString(form, overwrites = {})
{
let serialized = [];
let data = new FormData(form);
// Loop through each field in the form
for (let i = 0; i < form.elements.length; i++) {
let field = form.elements[i];
// Don't serialize a couple of field types (button and submit are important to exclude, eg. invoice preview would fail otherwise)
if (!field.name || field.disabled || field.type === 'file' || field.type === 'reset' || field.type === 'submit' || field.type === 'button') {
continue;
}
// If a multi-select, get all selections
if (field.type === 'select-multiple') {
for (var n = 0; n < field.options.length; n++) {
if (!field.options[n].selected) {
continue;
}
serialized.push({
name: field.name,
value: field.options[n].value
});
}
} else if ((field.type !== 'checkbox' && field.type !== 'radio') || field.checked) {
serialized.push({
name: field.name,
value: field.value
});
}
for (const key in overwrites) {
data.set(key, overwrites[key]);
}
return serialized;
}
convertFormDataToQueryString(formData) {
let serialized = [];
for (let row of formData) {
serialized.push(encodeURIComponent(row.name) + "=" + encodeURIComponent(row.value));
for (let row of data) {
serialized.push(encodeURIComponent(row[0]) + "=" + encodeURIComponent(row[1]));
}
return serialized.join('&');

View File

@@ -118,9 +118,13 @@ export default class KimaiFormSelect extends KimaiPlugin {
options = {...options, ...{
language: this.getConfiguration('locale').replace('_', '-'),
theme: "bootstrap",
matcher: this.matcher
matcher: this.matcher,
dropdownAutoWidth: true,
width: "resolve"
}};
const element = jQuery(node);
if (node.dataset['renderer'] !== undefined && node.dataset['renderer'] === 'color') {
const templateResultFunc = function (state) {
return jQuery('<span><span style="background-color:'+state.id+'; width: 20px; height: 20px; display: inline-block; margin-right: 10px;">&nbsp;</span>' + state.text + '</span>');
@@ -131,10 +135,20 @@ export default class KimaiFormSelect extends KimaiPlugin {
templateResult: templateResultFunc
}};
jQuery(node).select2(colorOptions);
element.select2(colorOptions);
} else {
jQuery(node).select2(options);
element.select2(options);
}
// this is a bugfix for safari, which does render the dropdown only with correct width upon the second opening
// see https://github.com/select2/select2/issues/4678
element.on('select2:open', function (ev) {
if (element.data('performing-reopen') === undefined || element.data('performing-reopen') === null) {
element.data('performing-reopen', true);
element.select2('close');
element.select2('open');
}
});
}
activateSelectPicker(selector, container) {
@@ -161,18 +175,25 @@ export default class KimaiFormSelect extends KimaiPlugin {
let emptyOpts = [];
let options = [];
let titlePattern = null;
if (select[0] !== undefined && select[0].dataset !== undefined && select[0].dataset['optionPattern'] !== undefined) {
titlePattern = select[0].dataset['optionPattern'];
}
if (titlePattern === null || titlePattern === '') {
titlePattern = '{name}';
}
for (const [key, value] of Object.entries(data)) {
if (key === '__empty__') {
for (const entity of value) {
emptyOpts.push(this._createOption(entity.name, entity.id));
emptyOpts.push(this._createOption(this._getTitleFromPattern(titlePattern, entity), entity.id));
}
continue;
}
let optGroup = this._createOptgroup(key);
for (const entity of value) {
optGroup.appendChild(this._createOption(entity.name, entity.id));
optGroup.appendChild(this._createOption(this._getTitleFromPattern(titlePattern, entity), entity.id));
}
options.push(optGroup);
}
@@ -183,6 +204,23 @@ export default class KimaiFormSelect extends KimaiPlugin {
// if available, re-select the previous selected option (mostly usable for global activities)
select.val(selectedValue);
// pre-select an option if it is the only available one
if (select.val() === '') {
const allOptions = select.find('option');
const optionLength = allOptions.length;
let selectOption = '';
if (optionLength === 1) {
selectOption = allOptions[0].value;
} else if (optionLength === 2 && emptyOption.length === 1) {
selectOption = allOptions[1].value;
}
if (selectOption !== '') {
select.val(selectOption);
}
}
// if we don't trigger the change, the other selects won't reset
select.trigger('change');
@@ -192,6 +230,48 @@ export default class KimaiFormSelect extends KimaiPlugin {
}
}
/**
* @param {string} pattern
* @param {array} entity
* @private
*/
_getTitleFromPattern(pattern, entity) {
const DATE_UTILS = this.getPlugin('date');
const regexp = new RegExp('{[^}]*?}','g');
let title = pattern;
let match = null;
while ((match = regexp.exec(pattern)) !== null) {
const field = match[0].substr(1, match[0].length - 2);
let value = entity[field] === undefined ? null : entity[field];
if ((field === 'start' || field === 'end')) {
if (value === null) {
value = '?';
} else {
value = DATE_UTILS.getFormattedDate(value);
}
}
title = title.replace(new RegExp('{' + field + '}', 'g'), value ?? '');
}
title = title.replace(/- \?-\?/, '');
title = title.replace(/\r\n|\r|\n/g, ' ');
title = title.substr(0, 110);
const chars = '- ';
let start = 0, end = title.length;
while (start < end && chars.indexOf(title[start]) >= 0) {
++start;
}
while (end > start && chars.indexOf(title[end - 1]) >= 0) {
--end;
}
return (start > 0 || end < title.length) ? title.substring(start, end) : title;
}
/**
* @param {string} label
* @param {string} value

View File

@@ -65,9 +65,10 @@ export default class KimaiRecentActivities extends KimaiPlugin {
for (let timesheet of entries) {
let label = this.attributes['template']
.replace('%customer%', timesheet.project.customer.name)
.replace('%project%', timesheet.project.name)
.replace('%activity%', timesheet.activity.name);
.replace('%customer%', this.escape(timesheet.project.customer.name))
.replace('%project%', this.escape(timesheet.project.name))
.replace('%activity%', this.escape(timesheet.activity.name))
;
htmlToInsert +=
`<li>` +

View File

@@ -142,7 +142,9 @@ export default class KimaiSelectDataAPI extends KimaiPlugin {
ordered[key] = options[key];
});
this.getContainer().getPlugin('form-select').updateOptions(selectName, ordered);
/** @var {KimaiFormSelect} select */
const select = this.getContainer().getPlugin('form-select');
select.updateOptions(selectName, ordered);
}
}

View File

@@ -42,15 +42,6 @@ td {
margin-right: 15px;
}
.box-profile {
img.img-circle {
width: 100px;
}
.profile-username {
margin-top: 15px;
}
}
.label-gray {
background-color: $gray-lte;
color: #666;

View File

@@ -10,7 +10,8 @@
table.dataTable.table > tbody > tr > td {
vertical-align: middle;
.img-circle {
max-width: 30px;
width: 30px;
height: 30px;
margin-right: 0;
&.teamlead {
box-shadow: 0 0 5px 3px $gray-lte;

View File

@@ -52,4 +52,9 @@ form.form-narrow {
.checkbox-menu li.active label:hover,
.checkbox-menu li.active label:focus {
background-color: #b8b8ff;
}
.duration-input {
min-width:50px;
padding: 6px;
}

View File

@@ -34,6 +34,9 @@ p {
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
@@ -77,7 +80,7 @@ table.footer {
line-height: 14px;
}
table.footer td {
vertical-align: bottom;
vertical-align: top;
}
/* The invoice items list */
table.items {

View File

@@ -19,280 +19,287 @@
@import "~bootstrap-sass/assets/stylesheets/bootstrap/utilities";
@import "~bootstrap-sass/assets/stylesheets/bootstrap/responsive-utilities";
body.invoice_print {
background-color: #eee;
body {
font-family: $font-family-sans-serif;
&.invoice_print {
background-color: #eee;
.table.no-border, .table.no-border td, .table.no-border th {
border: 0;
}
h1, h2, h3 {
font-family: 'Source Sans Pro', sans-serif;
}
.invoice {
margin: 105px auto 30px auto;
padding: 50px 65px;
min-height: 297mm;
position: relative;
max-width: 210mm;
box-shadow: 0 0 20px rgba(80,80,80,0.7);
background: #fff;
border: none;
}
.page-header {
margin: 10px 0 20px 0;
font-size: 22px;
>small {
color: #666;
display: block;
margin-top: 5px;
.table.no-border, .table.no-border td, .table.no-border th {
border: 0;
}
h3 {
border-color: #ddd;
h1, h2, h3 {
font-family: $font-family-sans-serif;
}
}
/*
.page-header {
margin: 10px 0 20px 0;
font-size: 22px;
}
*/
div.invoice-address {
margin-top: 30px;
margin-bottom: 30px;
}
table.invoice-meta th {
padding-right: 40px;
}
table.invoice-sum th {
width: 70%;
}
table.invoice-sum th, table.invoice-sum td {
text-align: right;
}
.invoice {
margin: 105px auto 30px auto;
padding: 50px 65px;
min-height: 297mm;
position: relative;
max-width: 210mm;
box-shadow: 0 0 20px rgba(80, 80, 80, 0.7);
background: #fff;
border: none;
}
.invoice-items {
margin-top: 2em;
margin-bottom: 3em;
.page-header {
margin: 10px 0 20px 0;
font-size: 22px;
.table {
thead th {
font-weight: bold;
border-bottom: 1px solid #ddd;
padding-bottom: 15px
> small {
color: #666;
display: block;
margin-top: 5px;
}
tfoot {
border-top: 1px solid #ddd;
tr:first-child th,
tr:first-child td {
padding-top: 15px
h3 {
border-color: #ddd;
}
}
div.invoice-address {
margin-top: 30px;
margin-bottom: 30px;
}
table.invoice-meta th {
padding-right: 40px;
}
table.invoice-sum th {
width: 70%;
}
table.invoice-sum th, table.invoice-sum td {
text-align: right;
}
.invoice-items {
margin-top: 2em;
margin-bottom: 3em;
.table {
thead th {
font-weight: bold;
border-bottom: 1px solid #ddd;
padding-bottom: 15px
}
tr {
th,td {
border:none;
padding: 4px 8px;
tfoot {
border-top: 1px solid #ddd;
tr:first-child th,
tr:first-child td {
padding-top: 15px
}
tr {
th, td {
border: none;
padding: 4px 8px;
}
}
}
}
}
}
.footer {
border-top: 1px solid #ccc;
margin-top: 50px;
font-size: 0.8em;
padding-top: 10px;
width: 100%;
}
/* ================================ FREELANCER INVOICE ================================ */
#freelancer-invoice {
box-sizing: border-box;
h1 {
font: bold 100% sans-serif;
letter-spacing: 0.5em;
text-align: center;
text-transform: uppercase;
}
h2 {
font: bold 1.5em sans-serif;
margin-bottom: 1em;
}
/* table */
table {
width: 100%;
}
table {
border-collapse: separate;
border-spacing: 0;
}
/* header */
header {
&:after {
clear: both;
content: "";
display: table;
font-size: 1em;
}
address {
float: left;
font-style: normal;
line-height: 1.25;
margin: 0 1em 1em 0;
font-size: 0.7em;
}
}
article address {
font-size: 1em;
float: left;
}
article.address {
margin:1em 0 1.5em 0;
}
/* article */
article:after {
clear: both;
content: "";
display: table;
}
article h1 {
clip: rect(0 0 0 0);
position: absolute;
}
article p {
margin-bottom: 20px;
}
/* table meta & balance */
table.meta:after, table.balance:after {
clear: both;
content: "";
display: table;
}
/* table meta */
table.meta {
float: right;
width: 50%;
font-size: 85%;
th {
text-align: right;
font-weight: normal;
}
td {
width: 120px;
text-align: right;
}
}
/* table balance */
table.balance {
float: right;
margin-top: 1em;
th {
text-align: right;
font-weight: normal;
}
td {
width: 140px;
text-align: right;
line-height: 22px;
}
.total {
font-weight: bold;
}
}
/* table items */
table.inventory {
clear: both;
width: 100%;
margin-top: 2em;
border-bottom: 1px solid #000;
}
table.inventory th,
table.inventory td {
padding: 5px;
}
table.inventory thead th {
font-weight: bold;
border-width: 0 0 1px 0;
border-bottom: 1px solid #000;
padding-bottom: 15px
}
table.inventory tbody tr:first-child td {
padding-top: 15px
}
table.inventory tbody tr:last-child td {
padding-bottom: 15px
}
.footer {
border-color: #000;
border-top: 1px solid #ccc;
margin-top: 50px;
font-size: 0.8em;
padding-top: 10px;
width: 100%;
}
}
/* ================================ PRINTING RULES ================================ */
/* ================================ FREELANCER INVOICE ================================ */
@media print {
* {
-webkit-print-color-adjust: exact;
}
body, .wrapper {
margin: 0;
padding: 0;
background-color: white;
}
#freelancer-invoice {
span:empty {
display: none;
box-sizing: border-box;
h1 {
font: bold 100% sans-serif;
letter-spacing: 0.5em;
text-align: center;
text-transform: uppercase;
}
.add, .cut {
display: none;
h2 {
font: bold 1.5em sans-serif;
margin-bottom: 1em;
}
/* table */
table {
width: 100%;
}
table {
border-collapse: separate;
border-spacing: 0;
}
/* header */
header {
&:after {
clear: both;
content: "";
display: table;
font-size: 1em;
}
address {
float: left;
font-style: normal;
line-height: 1.25;
margin: 0 1em 1em 0;
font-size: 0.7em;
}
}
article address {
font-size: 1em;
float: left;
}
article.address {
margin: 1em 0 1.5em 0;
}
/* article */
article:after {
clear: both;
content: "";
display: table;
}
article h1 {
clip: rect(0 0 0 0);
position: absolute;
}
article p {
margin-bottom: 20px;
}
/* table meta & balance */
table.meta:after, table.balance:after {
clear: both;
content: "";
display: table;
}
/* table meta */
table.meta {
float: right;
width: 50%;
font-size: 85%;
th {
text-align: right;
font-weight: normal;
}
td {
width: 120px;
text-align: right;
}
}
/* table balance */
table.balance {
float: right;
margin-top: 1em;
th {
text-align: right;
font-weight: normal;
}
td {
width: 140px;
text-align: right;
line-height: 22px;
}
.total {
font-weight: bold;
}
}
/* table items */
table.inventory {
clear: both;
width: 100%;
margin-top: 2em;
border-bottom: 1px solid #000;
}
table.inventory th,
table.inventory td {
padding: 5px;
}
table.inventory thead th {
font-weight: bold;
border-width: 0 0 1px 0;
border-bottom: 1px solid #000;
padding-bottom: 15px
}
table.inventory tbody tr:first-child td {
padding-top: 15px
}
table.inventory tbody tr:last-child td {
padding-bottom: 15px
}
.footer {
border-color: #000;
}
}
.invoice {
margin: 1em 2em;
font-size: 90%;
width: unset;
padding: 0;
min-height: unset;
position: unset;
max-width: unset;
box-shadow: unset;
/* ================================ PRINTING RULES ================================ */
@media print {
* {
-webkit-print-color-adjust: exact;
}
body, .wrapper {
margin: 0;
padding: 0;
background-color: white;
}
#freelancer-invoice {
span:empty {
display: none;
}
.add, .cut {
display: none;
}
}
.invoice {
margin: 1em 2em;
font-size: 90%;
width: unset;
padding: 0;
min-height: unset;
position: unset;
max-width: unset;
box-shadow: unset;
}
}
}
}
}

View File

@@ -15,10 +15,6 @@
}
.navbar-nav {
img.img-circle {
max-height: 26px;
margin-top: -2px;
}
.avatar {
max-height: 30px;
max-width: 30px;

View File

@@ -20,4 +20,10 @@
/* empty <option> were too small to be noticed as clickable element */
.select2-container--bootstrap .select2-results__option {
min-height: 30px;
}
}
/* Fixes the size on quick-entry form */
.select2-container--bootstrap {
display: table;
table-layout: fixed;
}

View File

@@ -5,28 +5,6 @@
* file that was distributed with this source code.
*/
/* UserPanel is deactivated right now */
/*
.user-panel>.info {
left: 80px;
}
*/
/* Right sidebar with additional tabs for personal settings and "About Kimai" section */
/*
.control-sidebar {
select {
color: $input-color;
background-color: $input-bg;
}
div.image {
img.img-circle {
max-width: 34px;
}
}
}
*/
@media (min-width: $screen-sm-min) {
.sidebar-mini.sidebar-collapse .sidebar-menu li.header {
border-top-width: 1px;
@@ -39,13 +17,3 @@
}
}
}
/*
Simplify focus detection, when using keyboard tab navigation
Deactivated, as it looks weird when the link is clicked
*/
/*
.sidebar-menu > li > a:focus {
text-decoration: underline;
}
*/

View File

@@ -10,7 +10,7 @@
}
],
"require": {
"php": ">=7.3",
"php": "7.3.*||7.4.*||8.0.*",
"ext-gd": "*",
"ext-intl": "*",
"ext-json": "*",
@@ -26,7 +26,7 @@
"doctrine/orm": "^2.8",
"erusev/parsedown": "^1.6",
"friendsofsymfony/rest-bundle": "^3.0",
"gedmo/doctrine-extensions": "^3.0",
"gedmo/doctrine-extensions": "^3.6",
"handcraftedinthealps/rest-routing-bundle": "^1.0",
"jms/metadata": "^2.0",
"jms/serializer-bundle": "^3.9",
@@ -78,12 +78,12 @@
"dama/doctrine-test-bundle": "^6.0",
"doctrine/doctrine-fixtures-bundle": "^3.2",
"fakerphp/faker": "^1.15",
"friendsofphp/php-cs-fixer": "^3.0",
"friendsofphp/php-cs-fixer": "^3.2",
"phpstan/phpstan": "^1.0",
"phpstan/phpstan-doctrine": "^1.0",
"phpstan/phpstan-phpunit": "^1.0",
"phpstan/phpstan-symfony": "^1.0",
"phpunit/phpunit": "^8.0",
"phpunit/phpunit": "^9.0",
"symfony/browser-kit": "^4.4",
"symfony/css-selector": "^4.4",
"symfony/debug-bundle": "^4.4",
@@ -107,7 +107,11 @@
"preferred-install": {
"*": "dist"
},
"sort-packages": true
"sort-packages": true,
"allow-plugins": {
"composer/package-versions-deprecated": true,
"symfony/flex": true
}
},
"autoload": {
"psr-4": {

3241
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -101,6 +101,7 @@ kimai:
RATE: ['view_rate_own_timesheet','edit_rate_own_timesheet']
RATE_OTHER: ['view_rate_other_timesheet','edit_rate_other_timesheet']
EXPORT: ['create_export','edit_export_own_timesheet','edit_export_other_timesheet']
BILLABLE: ['edit_billable_own_timesheet','edit_billable_other_timesheet']
TEAMS: ['view_team','create_team','edit_team','delete_team']
LOCKDOWN: ['lockdown_grace_timesheet','lockdown_override_timesheet']
REPORTING: ['view_reporting','view_other_reporting']
@@ -111,9 +112,9 @@ kimai:
SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data']
# link above sets to one complete set for each user role
ROLE_USER: ['@TIMESHEET','@PROFILE','@REPORTING','@SINGLE_USER']
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@REPORTING','@SINGLE_TEAMLEAD']
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@LOCKDOWN','@REPORTING','@SINGLE_ADMIN']
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@LOCKDOWN','@REPORTING','@SINGLE_SUPER_ADMIN']
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@BILLABLE','@TAGS','@REPORTING','@SINGLE_TEAMLEAD']
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@BILLABLE','@TAGS','@LOCKDOWN','@REPORTING','@SINGLE_ADMIN']
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@BILLABLE','@TAGS','@LOCKDOWN','@REPORTING','@SINGLE_SUPER_ADMIN']
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"])
maps:
ROLE_USER: ['ROLE_USER']

View File

@@ -9,6 +9,7 @@ monolog:
- ^/
nested:
type: stream
level: info
path: "%kernel.logs_dir%/%kernel.environment%.log"
console:

View File

@@ -2,7 +2,7 @@
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters:
locale: en
app_locales: ar|cs|da|de|de_AT|de_CH|el|en|eo|es|eu|fi|fo|fr|he|hr|hu|it|ja|ko|nl|pl|pt|pt_BR|ro|ru|sk|sv|tr|vi|zh_CN
app_locales: ar|cs|da|de|de_AT|de_CH|el|en|eo|es|eu|fa|fi|fo|fr|he|hr|hu|it|ja|ko|nb_NO|nl|pl|pt|pt_BR|ro|ru|sk|sv|tr|vi|zh_CN
services:
# default configuration for services in *this* file

View File

@@ -36,8 +36,8 @@
"jquery-ui": "^1.12",
"js-cookie": "^2.2",
"moment": "^2.29",
"node-sass": "^4.14",
"sass-loader": "^10.0",
"node-sass": "^7.0",
"sass-loader": "^11.0",
"select2": "^4.0",
"select2-bootstrap-theme": "^0.1.0-beta.10",
"sweetalert2": "^10.16",

View File

@@ -1,8 +1,9 @@
includes:
- vendor/phpstan/phpstan-symfony/extension.neon
- vendor/phpstan/phpstan-doctrine/extension.neon
- vendor/phpstan/phpstan-symfony/rules.neon
- vendor/phpstan/phpstan/conf/bleedingEdge.neon
- %rootDir%/../phpstan-symfony/extension.neon
- %rootDir%/../phpstan-symfony/rules.neon
- %rootDir%/../phpstan-doctrine/extension.neon
- %rootDir%/../phpstan-doctrine/rules.neon
- %rootDir%/../phpstan/conf/bleedingEdge.neon
parameters:
tmpDir: %rootDir%/../../../var/cache/phpstan
@@ -11,4 +12,8 @@ parameters:
excludePaths:
- %rootDir%/../../../src/Ldap/LdapDriver.php
treatPhpDocTypesAsCertain: false
inferPrivatePropertyTypeFromConstructor: true
inferPrivatePropertyTypeFromConstructor: true
doctrine:
objectManagerLoader: %rootDir%/../../../tests/phpstan-doctrine.php
symfony:
containerXmlPath: %rootDir%/../../../var/cache/dev/srcApp_KernelDevDebugContainer.xml

View File

@@ -1,32 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.de/manual/current/en/appendixes.configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/6.1/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="tests/bootstrap.php"
>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" backupGlobals="false" colors="true" bootstrap="tests/bootstrap.php">
<php>
<ini name="error_reporting" value="-1" />
<ini name="max_execution_time" value="-1" />
<ini name="intl.default_locale" value="en_US" />
<ini name="error_reporting" value="-1"/>
<ini name="max_execution_time" value="-1"/>
<ini name="intl.default_locale" value="en_US"/>
<env name="KERNEL_CLASS" value="App\Kernel" force="true"/>
<env name="SYMFONY_DEPRECATIONS_HELPER" value="weak"/>
<env name="APP_ENV" value="test" force="true"/>
<env name="APP_DEBUG" value="1" force="true"/>
<env name="APP_SECRET" value="5a79a1c866efef9ca1800f971d689f3e" force="true"/>
<!--
CREATE DATABASE IF NOT EXISTS `kimai2_test`;
CREATE USER IF NOT EXISTS `kimai2_test`@127.0.0.1;
ALTER USER `kimai2_test`@127.0.0.1 IDENTIFIED BY "kimai2_test";
GRANT execute,select,insert,update,delete,create,alter,drop,index,references ON `kimai2_test`.* TO kimai2_test@127.0.0.1;
CREATE DATABASE IF NOT EXISTS `kimai2_test`;
CREATE USER IF NOT EXISTS `kimai2_test`@127.0.0.1;
ALTER USER `kimai2_test`@127.0.0.1 IDENTIFIED BY "kimai2_test";
GRANT execute,select,insert,update,delete,create,alter,drop,index,references ON `kimai2_test`.* TO kimai2_test@127.0.0.1;
-->
<env name="DATABASE_URL" value="mysql://kimai2_test:kimai2_test@127.0.0.1:3306/kimai2_test"/>
<env name="CORS_ALLOW_ORIGIN" value="^https?://localhost(:[0-9]+)?$"/>
<env name="MAILER_URL" value="null://null"/>
<env name="MAILER_FROM" value="kimai@example.com"/>
<!--
REINSTALL THE TEST DATABASE, eg. AFTER CHANGING STRUCTURE!
@@ -44,35 +37,34 @@
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">src/</directory>
<directory suffix=".php">templates/</directory>
<exclude>
<directory suffix=".php">src/Migrations/</directory>
<directory suffix=".php">assets/</directory>
<directory suffix=".php">bin/</directory>
<directory suffix=".php">config/</directory>
<directory suffix=".php">node_modules/</directory>
<directory suffix=".php">public/</directory>
<directory suffix=".php">tests/</directory>
<directory suffix=".php">translations/</directory>
<directory suffix=".php">var/</directory>
<directory suffix=".php">vendor/</directory>
</exclude>
</whitelist>
</filter>
</include>
<exclude>
<directory suffix=".php">src/Migrations/</directory>
<directory suffix=".php">assets/</directory>
<directory suffix=".php">bin/</directory>
<directory suffix=".php">config/</directory>
<directory suffix=".php">node_modules/</directory>
<directory suffix=".php">public/</directory>
<directory suffix=".php">tests/</directory>
<directory suffix=".php">translations/</directory>
<directory suffix=".php">var/</directory>
<directory suffix=".php">vendor/</directory>
</exclude>
</coverage>
<!--
begins a database transaction before every testcase and rolls it back after the test finished, so
tests can manipulate the database without affecting other tests
@see https://github.com/dmaicher/doctrine-test-bundle
wrap tests inside database transactions, so tests can safely manipulate contents without
affecting other tests. @see https://github.com/dmaicher/doctrine-test-bundle
-->
<extensions>
<extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension" />
<extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
<listeners>
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener"/>
</listeners>
</phpunit>
</phpunit>

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

@@ -91,6 +91,10 @@
* [KIMAI] KimaiDateUtils: responsible for handling date specific tasks
*/
/*!
* [KIMAI] KimaiEscape: sanitize strings
*/
/*!
* [KIMAI] KimaiEvent: helper to trigger events
*/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,10 +3,10 @@
"app": {
"js": [
"build/runtime.b8e7bb04.js",
"build/app.3947eaef.js"
"build/app.a8c84f0c.js"
],
"css": [
"build/app.3bc2b4d9.css"
"build/app.7beab68a.css"
]
},
"invoice": {
@@ -15,7 +15,7 @@
"build/invoice.19f36eca.js"
],
"css": [
"build/invoice.ff32661a.css"
"build/invoice.ccdecd42.css"
]
},
"invoice-pdf": {
@@ -24,7 +24,7 @@
"build/invoice-pdf.0ce1f3ce.js"
],
"css": [
"build/invoice-pdf.9a7468ef.css"
"build/invoice-pdf.e73a6dda.css"
]
},
"chart": {
@@ -36,7 +36,7 @@
"calendar": {
"js": [
"build/runtime.b8e7bb04.js",
"build/calendar.52d75f80.js"
"build/calendar.ba553253.js"
],
"css": [
"build/calendar.25e7e872.css"

View File

@@ -1 +0,0 @@
body{font-family:sans-serif;font-size:10pt}body,h1,h2,h3,h4,h5,p,table,td,th,tr{margin:0;padding:0}th{border:none;text-align:left}td{vertical-align:top}p{margin-bottom:12px}.text-small{font-size:7pt}.padding-left{padding-left:15px}.text-center{text-align:center}.text-right{text-align:right}.text-nowrap{white-space:nowrap}.date{color:#666;font-size:80%;text-align:right;padding-top:6px}.wrapper{margin:4px}table{width:100%;border-spacing:0}p,table.addresses{line-height:14pt}table.addresses{margin-bottom:30px}table.header{padding-bottom:4px;border-bottom:1px solid #999}table.header .title{font-weight:400;font-size:140%}table.footer{border-top:1px solid #999;font-size:80%;padding-top:8px;line-height:14px}table.footer td{vertical-align:bottom}table.items{border-collapse:collapse;margin-top:50px;margin-bottom:60px}table.items tbody td,table.items thead th{border-bottom:1px solid #ccc}table.items tbody td.first,table.items thead th.first{padding-left:0}table.items tbody td.last,table.items tfoot td.last,table.items thead th.last{padding-right:0}table.items td,table.items th{padding:8px 6px}table.items tfoot td,table.items tfoot th{padding:20px 0 0;line-height:1px}table.items tfoot td.last{padding-left:10px}table.items tr.odd{background-color:#f5f5f5}

View File

@@ -0,0 +1 @@
body{font-family:sans-serif;font-size:10pt}body,h1,h2,h3,h4,h5,p,table,td,th,tr{margin:0;padding:0}th{border:none;text-align:left}td{vertical-align:top}p{margin-bottom:12px}.text-small{font-size:7pt}.padding-left{padding-left:15px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.text-nowrap{white-space:nowrap}.date{color:#666;font-size:80%;text-align:right;padding-top:6px}.wrapper{margin:4px}table{width:100%;border-spacing:0}p,table.addresses{line-height:14pt}table.addresses{margin-bottom:30px}table.header{padding-bottom:4px;border-bottom:1px solid #999}table.header .title{font-weight:400;font-size:140%}table.footer{border-top:1px solid #999;font-size:80%;padding-top:8px;line-height:14px}table.footer td{vertical-align:top}table.items{border-collapse:collapse;margin-top:50px;margin-bottom:60px}table.items tbody td,table.items thead th{border-bottom:1px solid #ccc}table.items tbody td.first,table.items thead th.first{padding-left:0}table.items tbody td.last,table.items tfoot td.last,table.items thead th.last{padding-right:0}table.items td,table.items th{padding:8px 6px}table.items tfoot td,table.items tfoot th{padding:20px 0 0;line-height:1px}table.items tfoot td.last{padding-left:10px}table.items tr.odd{background-color:#f5f5f5}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +1,13 @@
{
"build/app.css": "build/app.3bc2b4d9.css",
"build/app.js": "build/app.3947eaef.js",
"build/invoice.css": "build/invoice.ff32661a.css",
"build/app.css": "build/app.7beab68a.css",
"build/app.js": "build/app.a8c84f0c.js",
"build/invoice.css": "build/invoice.ccdecd42.css",
"build/invoice.js": "build/invoice.19f36eca.js",
"build/invoice-pdf.css": "build/invoice-pdf.9a7468ef.css",
"build/invoice-pdf.css": "build/invoice-pdf.e73a6dda.css",
"build/invoice-pdf.js": "build/invoice-pdf.0ce1f3ce.js",
"build/chart.js": "build/chart.e845e930.js",
"build/calendar.css": "build/calendar.25e7e872.css",
"build/calendar.js": "build/calendar.52d75f80.js",
"build/calendar.js": "build/calendar.ba553253.js",
"build/runtime.js": "build/runtime.b8e7bb04.js",
"build/images/fa-solid-900.svg": "build/images/fa-solid-900.13de59f1.svg",
"build/images/fa-brands-400.svg": "build/images/fa-brands-400.216edb96.svg",

View File

@@ -15,7 +15,6 @@ use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Team;
use App\Entity\TeamMember;
use App\Entity\User;
use App\Form\API\TeamApiEditForm;
use App\Repository\ActivityRepository;
@@ -201,7 +200,7 @@ final class TeamController extends BaseApiController
* Update an existing team
*
* @SWG\Patch(
* description="Update an existing team, you can pass all or just a subset of all attributes (passing users will replace all existing ones)",
* description="Update an existing team, you can pass all or just a subset of all attributes (passing members will replace all existing ones)",
* @SWG\Response(
* response=200,
* description="Returns the updated team",
@@ -235,11 +234,12 @@ final class TeamController extends BaseApiController
throw new NotFoundException();
}
// cache the current memberlist
/** @var TeamMember[] $originalMembers */
$originalMembers = [];
foreach ($team->getMembers() as $member) {
$originalMembers[] = $member;
if ($request->request->has('members')) {
foreach ($team->getMembers() as $member) {
$team->removeMember($member);
$this->repository->removeTeamMember($member);
}
$this->repository->saveTeam($team);
}
$form = $this->createForm(TeamApiEditForm::class, $team);
@@ -254,14 +254,6 @@ final class TeamController extends BaseApiController
return $this->viewHandler->handle($view);
}
// and now remove the ones, which are not in the list any longer
foreach ($originalMembers as $member) {
if (!$team->hasMember($member)) {
$member->getUser()->removeMembership($member);
$this->repository->removeTeamMember($member);
}
}
$this->repository->saveTeam($team);
$view = new View($team, Response::HTTP_OK);

View File

@@ -36,6 +36,7 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Validator\Constraints;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
@@ -122,6 +123,7 @@ class TimesheetController extends BaseApiController
* @Rest\QueryParam(name="end", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Only records before this date will be included (format: HTML5)")
* @Rest\QueryParam(name="exported", requirements="0|1", strict=true, nullable=true, description="Use this flag if you want to filter for export state. Allowed values: 0=not exported, 1=exported (default: all)")
* @Rest\QueryParam(name="active", requirements="0|1", strict=true, nullable=true, description="Filter for running/active records. Allowed values: 0=stopped, 1=active (default: all)")
* @Rest\QueryParam(name="billable", requirements="0|1", strict=true, nullable=true, description="Filter for non-/billable records. Allowed values: 0=non-billable, 1=billable (default: all)")
* @Rest\QueryParam(name="full", requirements="true", strict=true, nullable=true, description="Allows to fetch fully serialized objects including subresources. Allowed values: true (default: false)")
* @Rest\QueryParam(name="term", description="Free search term")
* @Rest\QueryParam(name="modified_after", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Only records changed after this date will be included (format: HTML5). Available since Kimai 1.10 and works only for records that were created/updated since then.")
@@ -218,6 +220,15 @@ class TimesheetController extends BaseApiController
}
}
if (null !== ($billable = $paramFetcher->get('billable'))) {
$billable = (int) $billable;
if ($billable === 1) {
$query->setBillable(true);
} elseif ($billable === 0) {
$query->setBillable(false);
}
}
if (null !== ($exported = $paramFetcher->get('exported'))) {
$exported = (int) $exported;
if ($exported === 1) {
@@ -316,6 +327,7 @@ class TimesheetController extends BaseApiController
$form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
'include_rate' => $this->isGranted('edit_rate', $timesheet),
'include_exported' => $this->isGranted('edit_export', $timesheet),
'include_billable' => $this->isGranted('edit_billable', $timesheet),
'include_user' => $this->isGranted('create_other_timesheet'),
'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(),
'allow_end_datetime' => $mode->canUpdateTimesWithAPI(),
@@ -393,6 +405,7 @@ class TimesheetController extends BaseApiController
$form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
'include_rate' => $this->isGranted('edit_rate', $timesheet),
'include_exported' => $this->isGranted('edit_export', $timesheet),
'include_billable' => $this->isGranted('edit_billable', $timesheet),
'include_user' => $this->isGranted('edit', $timesheet),
'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(),
'allow_end_datetime' => $mode->canUpdateTimesWithAPI(),
@@ -613,12 +626,10 @@ class TimesheetController extends BaseApiController
@trigger_error('Setting the "copy" attribute in "restart timesheet" API to something else then "all" is deprecated', E_USER_DEPRECATED);
}
$copyTimesheet
->setHourlyRate($timesheet->getHourlyRate())
->setFixedRate($timesheet->getFixedRate())
->setDescription($timesheet->getDescription())
->setBillable($timesheet->isBillable())
;
$copyTimesheet->setHourlyRate($timesheet->getHourlyRate());
$copyTimesheet->setFixedRate($timesheet->getFixedRate());
$copyTimesheet->setDescription($timesheet->getDescription());
$copyTimesheet->setBillable($timesheet->isBillable());
foreach ($timesheet->getTags() as $tag) {
$copyTimesheet->addTag($tag);
@@ -704,6 +715,11 @@ class TimesheetController extends BaseApiController
public function exportAction(Timesheet $id): Response
{
$timesheet = $id;
if ($timesheet->isExported() && !$this->isGranted('edit_exported_timesheet')) {
throw new AccessDeniedHttpException('User cannot edit an exported timesheet');
}
$timesheet->setExported(!$timesheet->isExported());
$this->service->updateTimesheet($timesheet);

View File

@@ -114,6 +114,7 @@ final class CalendarService
'timeframeBegin' => $this->configuration->getCalendarTimeframeBegin(),
'timeframeEnd' => $this->configuration->getCalendarTimeframeEnd(),
'dragDropAmount' => $this->configuration->getCalendarDragAndDropMaxEntries(),
'entryTitlePattern' => $this->configuration->find('calendar.title_pattern'),
];
$event = new CalendarConfigurationEvent($config);

View File

@@ -0,0 +1,40 @@
<?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 Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
abstract class AbstractUserCommand extends Command
{
protected function askForPassword(InputInterface $input, OutputInterface $output): string
{
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$passwordQuestion = new Question('Please enter the password: ');
$passwordQuestion->setHidden(true);
$passwordQuestion->setHiddenFallback(false);
$passwordQuestion->setValidator(function (?string $value) {
$password = trim($value);
if (empty($password)) {
throw new \Exception('The password may not be empty');
}
return $value;
});
$passwordQuestion->setMaxAttempts(3);
return $helper->ask($input, $output, $passwordQuestion);
}
}

View File

@@ -12,12 +12,11 @@ namespace App\Command;
use App\User\UserService;
use App\Utils\CommandStyle;
use App\Validator\ValidationFailedException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ChangePasswordCommand extends Command
final class ChangePasswordCommand extends AbstractUserCommand
{
private $userService;
@@ -35,7 +34,7 @@ class ChangePasswordCommand extends Command
->setDescription('Change the password of a user.')
->setDefinition([
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
new InputArgument('password', InputArgument::REQUIRED, 'The password'),
new InputArgument('password', InputArgument::OPTIONAL, 'The password'),
])
->setHelp(
<<<'EOT'
@@ -59,7 +58,12 @@ EOT
protected function execute(InputInterface $input, OutputInterface $output)
{
$username = $input->getArgument('username');
$password = $input->getArgument('password');
if (null !== $input->getArgument('password')) {
$password = $input->getArgument('password');
} else {
$password = $this->askForPassword($input, $output);
}
$user = $this->userService->findUserByUsernameOrThrowException($username);

View File

@@ -13,14 +13,11 @@ use App\Entity\User;
use App\User\UserService;
use App\Utils\CommandStyle;
use App\Validator\ValidationFailedException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
final class CreateUserCommand extends Command
final class CreateUserCommand extends AbstractUserCommand
{
private $userService;
@@ -91,31 +88,4 @@ final class CreateUserCommand extends Command
return 0;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
*
* @return string
*/
protected function askForPassword(InputInterface $input, OutputInterface $output): string
{
/* @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$passwordQuestion = new Question('Please enter the password: ');
$passwordQuestion->setHidden(true);
$passwordQuestion->setHiddenFallback(false);
$passwordQuestion->setValidator(function (?string $value) {
$password = trim($value);
if (empty($password)) {
throw new \Exception('The password may not be empty');
}
return $value;
});
$passwordQuestion->setMaxAttempts(3);
return $helper->ask($input, $output, $passwordQuestion);
}
}

View File

@@ -337,9 +337,10 @@ class InvoiceCreateCommand extends Command
return $invoices;
}
private function saveInvoicePreview(Response $response)
private function saveInvoicePreview(Response $response): string
{
$filename = uniqid('invoice_');
$directory = rtrim($this->previewDirectory, '/') . '/';
if ($response->headers->has('Content-Disposition')) {
$disposition = $response->headers->get('Content-Disposition');
@@ -360,12 +361,12 @@ class InvoiceCreateCommand extends Command
if ($response instanceof BinaryFileResponse) {
$file = $response->getFile();
$file->move($this->previewDirectory, $filename);
$file->move($directory, $filename);
} else {
(new Filesystem())->dumpFile($this->previewDirectory . $filename, $response->getContent());
(new Filesystem())->dumpFile($directory . $filename, $response->getContent());
}
return $this->previewDirectory . $filename;
return $directory . $filename;
}
/**

View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Command;
use App\Timesheet\TimesheetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* @codeCoverageIgnore
*/
class TimesheetStopAllCommand extends Command
{
private $timesheetService;
public function __construct(TimesheetService $timesheetService)
{
parent::__construct();
$this->timesheetService = $timesheetService;
}
protected function configure(): void
{
$this->setName('kimai:timesheet:stop-all');
$this->setDescription('Stop all running timesheets immediately');
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$amount = $this->timesheetService->stopAll();
if (!$output->isQuiet()) {
$io = new SymfonyStyle($input, $output);
$io->success(sprintf('Stopped %s timesheet records.', $amount));
}
return 0;
}
}

View File

@@ -0,0 +1,470 @@
<?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\Kernel;
use App\Utils\LanguageService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\HttpClient\HttpClient;
/**
* Command used to execute all the basic application bootstrapping AFTER "composer install" was executed.
*
* @codeCoverageIgnore
*/
class TranslationCommand extends Command
{
private $projectDirectory;
private $environment;
private $languageService;
public function __construct(string $projectDirectory, string $kernelEnvironment, LanguageService $languageService)
{
parent::__construct();
$this->projectDirectory = $projectDirectory;
$this->environment = $kernelEnvironment;
$this->languageService = $languageService;
}
/**
* {@inheritdoc}
*/
protected function configure()
{
$this
->setName('kimai:translations')
->setDescription('Translation adjustments')
->addOption('resname', null, InputOption::VALUE_NONE, 'Fix the resname vs. id attribute')
->addOption('duplicates', null, InputOption::VALUE_NONE, 'Find duplicate translation keys')
->addOption('delete-resname', null, InputOption::VALUE_REQUIRED, 'Deletes the translation by resname')
->addOption('extension', null, InputOption::VALUE_NONE, 'Find translation files with wrong extensions')
->addOption('fill-empty', null, InputOption::VALUE_NONE, 'Pre-fills empty translations with the english version')
// DEEPL TRANSLATION FEATURE - UNTESTED
->addOption('translate-locale', null, InputOption::VALUE_REQUIRED, 'Translate into the given locale with Deepl')
// @see https://www.deepl.com/de/pro#developer
->addOption('translate-deepl', null, InputOption::VALUE_REQUIRED, 'Translate using the "DeepL API Free" auth-key')
;
}
public function isEnabled(): bool
{
return $this->environment !== 'prod';
}
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
$io = new SymfonyStyle($input, $output);
$bases = [
'core' => $this->projectDirectory . '/translations/*.xlf',
'core_xliff' => $this->projectDirectory . '/translations/*.xliff',
'plugins' => $this->projectDirectory . Kernel::PLUGIN_DIRECTORY . '/*/Resources/translations/*.xlf',
'plugins_xliff' => $this->projectDirectory . Kernel::PLUGIN_DIRECTORY . '/*/Resources/translations/*.xliff',
];
if ($input->getOption('delete-resname')) {
$files = glob($bases['core']);
foreach ($files as $file) {
$this->removeKey($file, $input->getOption('delete-resname'));
}
}
// ==========================================================================
// Fix resname vs. id
// ==========================================================================
if ($input->getOption('resname')) {
foreach ($bases as $directory) {
$files = glob($directory);
foreach ($files as $file) {
$this->fixXlfFile($file);
}
}
}
// ==========================================================================
// Fill empty translations with english version
// ==========================================================================
if ($input->getOption('fill-empty')) {
$translateFrom = ['de-CH' => 'de', 'de_CH' => 'de', 'pt_BR' => 'pt', 'pt-BR' => 'pt', 'pt' => 'pt_BR'];
$translations = [];
foreach ($bases as $directory) {
$files = glob($directory);
foreach ($files as $file) {
$base = basename($file);
$parts = explode('.', $base);
$name = $parts[0];
$fileLocale = $parts[1];
$fromLocale = 'en';
if (\array_key_exists($fileLocale, $translateFrom)) {
$fromLocale = $translateFrom[$fileLocale];
}
if (!\array_key_exists($fromLocale, $translations)) {
$translations[$fromLocale] = [];
}
if (!\array_key_exists($name, $translations[$fromLocale])) {
$fromLocaleName = str_replace('.' . $fileLocale . '.', '.' . $fromLocale . '.', $file);
if (!file_exists($fromLocaleName)) {
$io->error('Could not find translation file: ' . $fromLocaleName);
return 1;
}
$translations[$fromLocale][$name] = $this->getTranslations($fromLocaleName);
}
if (stripos($base, '.' . $fromLocale . '.xlf') !== false || stripos($base, '.' . $fromLocale . '.xliff') !== false) {
continue;
}
$this->fixEmptyTranslations($file, $translations[$fromLocale][$name]);
}
}
}
// ==========================================================================
// Find wrong file extensions
// ==========================================================================
if ($input->getOption('extension')) {
foreach ([$bases['core'], $bases['plugins']] as $directory) {
$files = glob($directory);
foreach ($files as $file) {
$file = str_replace($this->projectDirectory, '', $file);
$io->warning($file);
}
}
}
// ==========================================================================
// Find duplicate translation keys
// ==========================================================================
if ($input->getOption('duplicates')) {
$duplicates = [];
foreach ($bases as $directory) {
foreach (glob($directory) as $file) {
$xml = simplexml_load_file($file);
foreach ($xml->file->body->{'trans-unit'} as $unit) {
$n = (string) $unit['resname'];
if (!\array_key_exists($n, $duplicates)) {
$duplicates[$n] = [];
}
$b = explode('.', basename($file))[0];
if (!\in_array($b, $duplicates[$n])) {
$duplicates[$n][] = $b;
}
}
}
}
foreach ($duplicates as $id => $files) {
if (\count($files) > 1) {
$io->text($id . ' => ' . implode(', ', $files));
}
}
}
// ==========================================================================
// DEEPL
// ==========================================================================
$locale = $input->getOption('translate-locale');
$deepl = $input->getOption('translate-deepl');
if ($locale !== null && $deepl === null) {
$io->error('Missing "DeepL API Free" auth-key');
return 1;
}
if ($locale === null && $deepl !== null) {
$io->error('Missing translation locale');
return 1;
}
if ($locale !== null && $deepl !== null) {
// see https://github.com/octfx/DeepLy/blob/master/src/DeepLy.php
$deeplySupportedLanguages = [
'de' => 'DE',
'en' => 'EN-US',
'fr' => 'FR',
'it' => 'IT',
'ja' => 'JA',
'es' => 'ES',
'nl' => 'NL',
'pl' => 'PL',
'pt' => 'PT-PT', // ???
'pt_BR' => 'PT-BR', // ???
'ru' => 'RU',
'zh_CN' => 'ZH',
];
$locale = strtolower($locale);
if (!$this->languageService->isKnownLanguage($locale)) {
$io->error('Unknown locale given: ' . $locale);
return 1;
}
if (!\array_key_exists($locale, $deeplySupportedLanguages)) {
$io->error('Locale not supported by Deeply: ' . $locale);
return 1;
}
$allKeys = 0;
$enFiles = glob($bases['core'] . '/*.en.xlf');
$baseUrl = 'https://api-free.deepl.com/v2/translate';
$client = HttpClient::create([]);
foreach ($enFiles as $file) {
$enTrans = [];
$domain = explode('.', basename($file))[0];
$xml = simplexml_load_file($file);
foreach ($xml->file->body->{'trans-unit'} as $unit) {
$id = (string) $unit['id'];
$enTrans[$id] = [
'resname' => (string) $unit['resname'],
'source' => (string) $unit->source,
'target' => (string) $unit->target
];
$allKeys++;
}
$localeFile = $bases['core'] . '/' . $domain . '.' . $locale . '.xlf';
$translated = [];
if (file_exists($localeFile)) {
$xml2 = simplexml_load_file($localeFile);
foreach ($xml2->file->body->{'trans-unit'} as $unit) {
$id = (string) $unit['id'];
$translated[$id] = [
'resname' => (string) $unit['resname'],
'source' => (string) $unit->source,
'target' => (string) $unit->target
];
}
}
$missingIds = array_diff(array_keys($enTrans), array_keys($translated));
if (\count($missingIds) === 0) {
continue;
}
$io->title('Translating ' . $domain);
$progress = new ProgressBar($output, \count($missingIds));
foreach ($missingIds as $id) {
$progress->advance();
$values = $enTrans[$id];
$translated[$id] = $values;
$params = [
'auth_key' => $deepl,
//'split_sentences' => '1',
//'preserve_formatting' => '0',
'formality' => 'default',
'text' => $values['target'],
'source_lang' => 'en',
'target_lang' => $deeplySupportedLanguages[$locale],
];
$rawResponseData = null;
try {
$rawResponseData = $client->request('POST', $baseUrl, ['body' => $params]);
} catch (\Exception $exception) {
$io->error($exception->getMessage());
return 1;
}
$json = json_decode($rawResponseData->getContent(), true);
$translation = $json['translations'][0]['text'];
$translated[$id]['target'] = $translation;
}
$progress->finish();
$io->writeln(PHP_EOL);
$this->writeXliffFile($bases['core'], $domain, $locale, $translated);
}
}
return 0;
}
private function getTranslations(string $file): array
{
$translations = [];
$xml = simplexml_load_file($file);
foreach ($xml->file->body->{'trans-unit'} as $unit) {
if (!isset($unit['resname'])) {
throw new \Exception('Missing "resname" attribute in file: ' . $file);
}
$source = (string) $unit['resname'];
$translations[$source] = (string) $unit->target;
}
return $translations;
}
private function writeXliffFile(string $base, string $domain, string $locale, array $translations = []): void
{
$from = $base . '/' . $domain . '.en.xlf';
$to = $base . '/' . $domain . '.' . $locale . '.xlf';
copy($from, $to);
$xml = simplexml_load_file($to);
/** @var \SimpleXMLElement $fileNode */
$fileNode = $xml->file;
$fileNode->attributes()->{'target-language'} = $locale;
$fileNode->attributes()->{'original'} = $domain . '.en.xlf';
unset($xml->file->body);
$xmlDocument = new \DOMDocument('1.0', 'UTF-8');
$xmlDocument->preserveWhiteSpace = false;
$xmlDocument->formatOutput = true;
$xmlDocument->loadXML($xml->asXML());
$xpath = new \DOMXpath($xmlDocument);
$xpath->registerNamespace('ns', $xmlDocument->documentElement->namespaceURI);
$xmlContent = '';
foreach ($translations as $id => $values) {
$xmlContent .= sprintf(
'<trans-unit id="%s" resname="%s"><source>%s</source><target>%s</target></trans-unit>',
$id,
$values['resname'],
$values['source'],
$values['target']
);
}
$fragment = $xmlDocument->createDocumentFragment();
$fragment->appendXml('<body>' . $xmlContent . '</body>');
/** @var \DOMElement $element */
$element = $xpath->evaluate('/ns:xliff/ns:file')->item(0);
$element->appendChild($fragment);
file_put_contents($to, $xmlDocument->saveXML());
}
private function fixXlfFile(string $file): void
{
$xml = simplexml_load_file($file);
if (isset($xml->file->header)) {
unset($xml->file->header);
}
foreach ($xml->file->body->{'trans-unit'} as $unit) {
$source = $unit->source;
if (!isset($unit['resname'])) {
$unit['resname'] = $source;
}
$unit['id'] = $this->generateId($unit['resname']);
}
$xmlDocument = new \DOMDocument('1.0');
$xmlDocument->preserveWhiteSpace = false;
$xmlDocument->formatOutput = true;
$xmlDocument->loadXML($xml->asXML());
file_put_contents($file, $xmlDocument->saveXML());
}
private function fixEmptyTranslations(string $file, array $translations): void
{
$xml = simplexml_load_file($file);
$foundEmpty = false;
foreach ($xml->file->body->{'trans-unit'} as $unit) {
if (!isset($unit['resname'])) {
continue;
}
$key = (string) $unit['resname'];
$translation = (string) $unit->target;
if (\strlen($translation) > 0) {
continue;
}
if (!\array_key_exists($key, $translations)) {
throw new \Exception(
sprintf('Missing english translation for key: %s in file %s', $key, $file)
);
}
$unit->target[0] = $translations[$key];
$unit->target['state'] = 'needs-translation';
$foundEmpty = true;
}
if (!$foundEmpty) {
return;
}
$xmlDocument = new \DOMDocument('1.0');
$xmlDocument->preserveWhiteSpace = false;
$xmlDocument->formatOutput = true;
$xmlDocument->loadXML($xml->asXML());
file_put_contents($file, $xmlDocument->saveXML());
}
private function removeKey(string $file, string $key): void
{
$xml = simplexml_load_file($file);
/** @var \SimpleXMLElement $unit */
foreach ($xml->file->body->{'trans-unit'} as $unit) {
if (!isset($unit['resname'])) {
continue;
}
if ((string) $unit['resname'] === $key) {
$dom = dom_import_simplexml($unit);
$dom->parentNode->removeChild($dom);
break;
}
}
$xmlDocument = new \DOMDocument('1.0');
$xmlDocument->preserveWhiteSpace = false;
$xmlDocument->formatOutput = true;
$xmlDocument->loadXML($xml->asXML());
file_put_contents($file, $xmlDocument->saveXML());
}
private function generateId(string $source): string
{
return strtr(substr(base64_encode(hash('sha256', $source, true)), 0, 7), '/+', '._');
}
}

View File

@@ -30,9 +30,10 @@ class VersionCommand extends Command
->setName('kimai:version')
->setDescription('Receive version information')
->setHelp('This command allows you to fetch various version information about Kimai.')
->addOption('name', null, InputOption::VALUE_NONE, 'Display the major release name')
->addOption('short', null, InputOption::VALUE_NONE, 'Display the version only')
->addOption('number', null, InputOption::VALUE_NONE, 'Display the version identifier only only')
// @deprecated since 1.14.1
->addOption('name', null, InputOption::VALUE_NONE, 'DEPRECATED: Display the major release name')
->addOption('candidate', null, InputOption::VALUE_NONE, 'DEPRECATED: Display the current version candidate (e.g. "stable" or "dev")')
->addOption('semver', null, InputOption::VALUE_NONE, 'DEPRECATED: Semantical versioning (SEMVER) compatible version string')
;
@@ -46,6 +47,7 @@ class VersionCommand extends Command
$io = new SymfonyStyle($input, $output);
if ($input->getOption('semver')) {
@trigger_error('bin/console kimai:version --semver is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
$io->writeln(Constants::VERSION . '-' . Constants::STATUS);
return 0;
@@ -58,18 +60,26 @@ class VersionCommand extends Command
}
if ($input->getOption('name')) {
@trigger_error('bin/console kimai:version --name is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
$io->writeln(Constants::NAME);
return 0;
}
if ($input->getOption('candidate')) {
@trigger_error('bin/console kimai:version --candidate is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
$io->writeln(Constants::STATUS);
return 0;
}
$io->writeln(Constants::SOFTWARE . ' ' . Constants::VERSION . ' by Kevin Papst and contributors.');
if ($input->getOption('number')) {
$io->writeln((string) Constants::VERSION_ID);
return 0;
}
$io->writeln(sprintf('%s <info>%s</info> by Kevin Papst and contributors.', Constants::SOFTWARE, Constants::VERSION));
return 0;
}

View File

@@ -9,7 +9,10 @@
namespace App\Configuration;
final class SamlConfiguration
/**
* @CloudRequired
*/
final class SamlConfiguration implements SamlConfigurationInterface
{
private $configuration;

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Configuration;
interface SamlConfigurationInterface
{
public function isActivated(): bool;
public function getTitle(): string;
public function getAttributeMapping(): array;
public function getRolesAttribute(): ?string;
public function getRolesMapping(): array;
public function getConnection(): array;
}

View File

@@ -54,29 +54,8 @@ trait StringAccessibleConfigTrait
return;
}
// this foreach should be replaced by a better piece of code,
// especially the pointers could be a problem in the future
foreach ($this->getConfigurations($this->repository) as $configuration) {
$temp = explode('.', $configuration->getName());
$array = &$this->settings;
if ($temp[0] === $this->getPrefix()) {
$temp = \array_slice($temp, 1);
}
foreach ($temp as $key2) {
if (!\array_key_exists($key2, $array)) {
$array[$key2] = $configuration->getValue();
continue;
}
if (\is_array($array[$key2])) {
$array = &$array[$key2];
} elseif (\is_bool($array[$key2])) {
$array[$key2] = (bool) $configuration->getValue();
} elseif (\is_int($array[$key2])) {
$array[$key2] = (int) $configuration->getValue();
} else {
$array[$key2] = $configuration->getValue();
}
}
$this->set($configuration->getName(), $configuration->getValue());
}
$this->initialized = true;
@@ -100,7 +79,7 @@ trait StringAccessibleConfigTrait
/**
* @param string $key
* @return mixed
* @return string|int|bool|float|null|array
*/
public function find(string $key)
{
@@ -181,7 +160,7 @@ trait StringAccessibleConfigTrait
*/
public function offsetSet($offset, $value)
{
throw new \BadMethodCallException('SystemBundleConfiguration does not support offsetSet()');
$this->set($offset, $value);
}
/**
@@ -192,4 +171,43 @@ trait StringAccessibleConfigTrait
{
throw new \BadMethodCallException('SystemBundleConfiguration does not support offsetUnset()');
}
/**
* Set an array item to a given value using "dot" notation.
*
* If no key is given to the method, the entire array will be replaced.
*
* @see https://github.com/divineomega/array_undot
* @param string $key
* @param mixed $value
*
* @return array
*/
private function set(string $key, $value): array
{
$array = &$this->settings;
$keys = explode('.', $key);
while (\count($keys) > 1) {
$key = array_shift($keys);
if (!isset($array[$key]) || !\is_array($array[$key])) {
$array[$key] = [];
}
$array = &$array[$key];
}
$k = array_shift($keys);
if (\array_key_exists($k, $array)) {
if (\is_bool($array[$k])) {
$value = (bool) $value;
} elseif (\is_int($array[$k])) {
$value = (int) $value;
}
}
$array[$k] = $value;
return $array;
}
}

View File

@@ -232,11 +232,6 @@ class SystemConfiguration implements SystemBundleConfiguration
return (string) $this->find('timesheet.default_begin');
}
public function getTimesheetDefaultBillable(): bool
{
return (bool) $this->find('defaults.timesheet.billable');
}
public function isTimesheetAllowFutureTimes(): bool
{
return (bool) $this->find('timesheet.rules.allow_future_times');
@@ -352,6 +347,11 @@ class SystemConfiguration implements SystemBundleConfiguration
return $this->getIncrement('timesheet.time_increment', $this->getTimesheetDefaultRoundingEnd(), 0);
}
public function getQuickEntriesRecentAmount(): int
{
return $this->getIncrement('quick_entry.recent_activities', 5, 0) ?? 5;
}
// ========== Company configurations ==========
public function getFinancialYearStart(): ?string

View File

@@ -21,10 +21,7 @@ final class ThemeConfiguration implements \ArrayAccess
$this->systemConfiguration = $systemConfiguration;
}
/**
* @return bool
*/
public function offsetExists($offset)
public function offsetExists($offset): bool
{
return $this->systemConfiguration->has('theme.' . $offset);
}
@@ -42,7 +39,7 @@ final class ThemeConfiguration implements \ArrayAccess
* @param mixed $value
* @throws \BadMethodCallException
*/
public function offsetSet($offset, $value)
public function offsetSet($offset, $value): void
{
throw new \BadMethodCallException('ThemeConfiguration does not support offsetSet()');
}
@@ -51,7 +48,7 @@ final class ThemeConfiguration implements \ArrayAccess
* @param mixed $offset
* @throws \BadMethodCallException
*/
public function offsetUnset($offset)
public function offsetUnset($offset): void
{
throw new \BadMethodCallException('ThemeConfiguration does not support offsetUnset()');
}

View File

@@ -22,4 +22,14 @@ class ConsoleApplication extends Application
{
return Constants::VERSION;
}
/**
* Overwritten to prevent unwanted SF core messages to show up here.
*
* @return string
*/
public function getLongVersion()
{
return sprintf('%s <info>%s</info> (env: <comment>%s</>, debug: <comment>%s</>)', $this->getName(), $this->getVersion(), $this->getKernel()->getEnvironment(), $this->getKernel()->isDebug() ? 'true' : 'false');
}
}

View File

@@ -17,15 +17,15 @@ class Constants
/**
* The current release version
*/
public const VERSION = '1.16.0';
public const VERSION = '1.19.7';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 11600;
public const VERSION_ID = 11907;
/**
* The current release status, either "stable" or "dev"
*/
public const STATUS = 'prod';
public const STATUS = 'stable';
/**
* The software name
*/

View File

@@ -18,6 +18,7 @@ use App\Timesheet\DateTimeFactory;
use App\Utils\LocaleFormats;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as BaseAbstractController;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
@@ -39,6 +40,13 @@ abstract class AbstractController extends BaseAbstractController implements Serv
return $this->container->get('translator');
}
public function createFormForGetRequest(string $type = FormType::class, $data = null, array $options = []): FormInterface
{
return $this->container
->get('form.factory')
->createNamed('', $type, $data, $options);
}
private function getLogger(): LoggerInterface
{
return $this->container->get('logger');
@@ -207,6 +215,19 @@ abstract class AbstractController extends BaseAbstractController implements Serv
throw new \InvalidArgumentException('handleSearchForm() requires an instanceof BaseQuery as form data');
}
$actions = ['resetSearchFilter', 'removeDefaultQuery', 'setDefaultQuery'];
foreach ($actions as $action) {
if ($request->query->has($action)) {
if (!$this->isCsrfTokenValid('search', $request->query->get('_token'))) {
$this->flashError('action.csrf.error');
return false;
}
}
}
$request->query->remove('_token');
if ($request->query->has('resetSearchFilter')) {
$data->resetFilter();
$this->removeLastSearch($data);

View File

@@ -17,6 +17,7 @@ use App\Entity\ActivityRate;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Project;
use App\Entity\Team;
use App\Event\ActivityDetailControllerEvent;
use App\Event\ActivityMetaDefinitionEvent;
use App\Event\ActivityMetaDisplayEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
@@ -140,6 +141,11 @@ final class ActivityController extends AbstractController
$teams = $activity->getTeams();
}
// additional boxes by plugins
$event = new ActivityDetailControllerEvent($activity);
$this->dispatcher->dispatch($event);
$boxes = $event->getController();
return $this->render('activity/details.html.twig', [
'activity' => $activity,
'stats' => $stats,
@@ -147,6 +153,7 @@ final class ActivityController extends AbstractController
'team' => $defaultTeam,
'teams' => $teams,
'now' => $now,
'boxes' => $boxes
]);
}

View File

@@ -16,6 +16,7 @@ use App\Entity\CustomerComment;
use App\Entity\CustomerRate;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Team;
use App\Event\CustomerDetailControllerEvent;
use App\Event\CustomerMetaDefinitionEvent;
use App\Event\CustomerMetaDisplayEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
@@ -172,7 +173,7 @@ final class CustomerController extends AbstractController
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
}
$csrfTokenManager->refreshToken($token);
$csrfTokenManager->refreshToken('customer.delete_comment');
try {
$this->repository->deleteComment($comment);
@@ -219,7 +220,7 @@ final class CustomerController extends AbstractController
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
}
$csrfTokenManager->refreshToken($token);
$csrfTokenManager->refreshToken('customer.pin_comment');
$comment->setPinned(!$comment->isPinned());
try {
@@ -300,7 +301,6 @@ final class CustomerController extends AbstractController
$attachments = [];
$comments = null;
$teams = null;
$projects = null;
$rates = [];
$now = $this->getDateTimeFactory()->createDateTime();
@@ -331,6 +331,11 @@ final class CustomerController extends AbstractController
$teams = $customer->getTeams();
}
// additional boxes by plugins
$event = new CustomerDetailControllerEvent($customer);
$this->dispatcher->dispatch($event);
$boxes = $event->getController();
return $this->render('customer/details.html.twig', [
'customer' => $customer,
'comments' => $comments,
@@ -342,6 +347,7 @@ final class CustomerController extends AbstractController
'customer_now' => new \DateTime('now', $timezone),
'rates' => $rates,
'now' => $now,
'boxes' => $boxes
]);
}

View File

@@ -69,7 +69,7 @@ class DoctorController extends AbstractController
return $this->redirectToRoute('doctor');
}
$csrfTokenManager->refreshToken($token);
$csrfTokenManager->refreshToken('doctor.flush_log');
$logfile = $this->getLogFilename();

View File

@@ -13,11 +13,16 @@ use App\Configuration\SystemConfiguration;
use App\Entity\Customer;
use App\Entity\Invoice;
use App\Entity\InvoiceTemplate;
use App\Entity\MetaTableTypeInterface;
use App\Event\InvoiceCreatedMultipleEvent;
use App\Event\InvoiceDocumentsEvent;
use App\Event\InvoiceMetaDefinitionEvent;
use App\Event\InvoiceMetaDisplayEvent;
use App\Export\Spreadsheet\AnnotatedObjectExporter;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Form\InvoiceDocumentUploadForm;
use App\Form\InvoicePaymentDateForm;
use App\Form\InvoiceEditForm;
use App\Form\InvoiceTemplateForm;
use App\Form\Toolbar\InvoiceArchiveForm;
use App\Form\Toolbar\InvoiceToolbarForm;
@@ -48,21 +53,9 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
*/
final class InvoiceController extends AbstractController
{
/**
* @var ServiceInvoice
*/
private $service;
/**
* @var InvoiceTemplateRepository
*/
private $templateRepository;
/**
* @var InvoiceRepository
*/
private $invoiceRepository;
/**
* @var EventDispatcherInterface
*/
private $dispatcher;
public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $templateRepository, InvoiceRepository $invoiceRepository, EventDispatcherInterface $dispatcher)
@@ -77,7 +70,7 @@ final class InvoiceController extends AbstractController
* @Route(path="/", name="invoice", methods={"GET", "POST"})
* @Security("is_granted('view_invoice')")
*/
public function indexAction(Request $request, SystemConfiguration $configuration): Response
public function indexAction(Request $request, SystemConfiguration $configuration, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$this->templateRepository->hasTemplate()) {
if ($this->isGranted('manage_invoice_template')) {
@@ -87,6 +80,13 @@ final class InvoiceController extends AbstractController
}
$query = $this->getDefaultQuery();
$token = null;
if ($request->query->has('token')) {
$token = $request->query->get('token');
$request->query->remove('token');
}
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
if ($this->handleSearch($form, $request)) {
return $this->redirectToRoute('invoice');
@@ -98,6 +98,14 @@ final class InvoiceController extends AbstractController
if ($form->isValid() && $this->isGranted('create_invoice')) {
if ($request->query->has('createInvoice')) {
if (!$this->isCsrfTokenValid('invoice.create', $token)) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('invoice');
}
$csrfTokenManager->refreshToken('invoice.create');
try {
return $this->renderInvoice($query, $request);
} catch (Exception $ex) {
@@ -130,118 +138,109 @@ final class InvoiceController extends AbstractController
}
/**
* @Route(path="/preview/{customer}/{template}", name="invoice_preview", methods={"GET"})
* @Security("is_granted('view_invoice')")
* @Route(path="/preview/{customer}/{token}", name="invoice_preview", methods={"GET"})
* @Security("is_granted('access', customer)")
* @Security("is_granted('create_invoice')")
*/
public function previewAction(Customer $customer, InvoiceTemplate $template, Request $request, SystemConfiguration $configuration): Response
public function previewAction(Customer $customer, string $token, Request $request, SystemConfiguration $configuration): Response
{
if (!$this->templateRepository->hasTemplate()) {
return $this->redirectToRoute('invoice');
}
if (!$this->isCsrfTokenValid('invoice.preview', $token)) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('invoice');
}
// do not refresh token, preview is opening in new tabs and the listing page does not reload
// so the new token would not be loaded
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
$form->submit($request->query->all(), false);
if ($this->handleSearch($form, $request)) {
return $this->redirectToRoute('invoice');
}
if ($form->isValid() && $this->isGranted('create_invoice')) {
if ($form->isValid()) {
try {
$query->setTemplate($template);
$query->setCustomers([$customer]);
$model = $this->service->createModel($query);
return $this->service->renderInvoiceWithModel($model, $this->dispatcher);
} catch (Exception $ex) {
$this->logException($ex);
$this->flashError('action.update.error', ['%reason%' => 'Failed generating invoice preview: ' . $ex->getMessage()]);
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
} else {
$this->flashFormError($form);
}
return $this->redirectToRoute('invoice');
}
/**
* @Route(path="/save-invoice/{customer}/{template}", name="invoice_create", methods={"GET"})
* @Security("is_granted('view_invoice')")
* @Route(path="/save-invoice/{customer}/{template}/{token}", name="invoice_create", methods={"GET"})
* @Security("is_granted('access', customer)")
* @Security("is_granted('create_invoice')")
*/
public function createInvoiceAction(Customer $customer, InvoiceTemplate $template, Request $request, SystemConfiguration $configuration): Response
public function createInvoiceAction(Customer $customer, InvoiceTemplate $template, string $token, Request $request, SystemConfiguration $configuration, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$this->templateRepository->hasTemplate()) {
return $this->redirectToRoute('invoice');
}
if (!$this->isCsrfTokenValid('invoice.create', $token)) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('invoice');
}
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
$form->submit($request->query->all(), false);
if ($this->handleSearch($form, $request)) {
return $this->redirectToRoute('invoice');
}
if ($form->isValid() && $this->isGranted('create_invoice')) {
if ($form->isValid()) {
$query->setTemplate($template);
$query->setCustomers([$customer]);
return $this->renderInvoice($query, $request);
}
return $this->redirectToRoute('invoice');
}
private function getDefaultQuery(): InvoiceQuery
{
$factory = $this->getDateTimeFactory();
$begin = $factory->getStartOfMonth();
$end = $factory->getEndOfMonth();
$query = new InvoiceQuery();
$query->setBegin($begin);
$query->setEnd($end);
// limit access to data from teams
$query->setCurrentUser($this->getUser());
if (!$this->isGranted('view_other_timesheet')) {
// limit access to own data
$query->setUser($this->getUser());
}
return $query;
}
private function renderInvoice(InvoiceQuery $query, Request $request)
{
// use the current request locale as fallback, if no translation was configured
if (null !== $query->getTemplate() && null === $query->getTemplate()->getLanguage()) {
$query->getTemplate()->setLanguage($request->getLocale());
}
try {
$invoices = $this->service->createInvoices($query, $this->dispatcher);
$this->flashSuccess('action.update.success');
if (\count($invoices) === 1) {
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoices[0]->getId()]);
}
return $this->redirectToRoute('admin_invoice_list');
} catch (Exception $ex) {
$this->flashUpdateException($ex);
}
$this->flashFormError($form);
return $this->redirectToRoute('invoice');
}
/**
* @Route(path="/change-status/{id}/{status}", name="admin_invoice_status", methods={"GET", "POST"})
* @Route(path="/change-status/{id}/{status}/{token}", name="admin_invoice_status", methods={"GET", "POST"})
* @Security("is_granted('access', invoice.getCustomer())")
* @Security("is_granted('create_invoice')")
*/
public function changeStatusAction(Invoice $invoice, string $status, Request $request): Response
public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('admin_invoice_list');
}
if ($status === Invoice::STATUS_PAID) {
$form = $this->createPaymentDateForm($invoice, $status);
if (null === $invoice->getPaymentDate()) {
$invoice->setPaymentDate($this->getDateTimeFactory()->createDateTime());
$invoice->setIsPaid();
}
$form = $this->createInvoiceEditForm($invoice);
$form->handleRequest($request);
if (!$form->isSubmitted() || !$form->isValid()) {
return $this->render('invoice/payment_date_edit.html.twig', [
'invoice' => $invoice,
'form' => $form->createView()
]);
}
return $this->render('invoice/invoice_edit.html.twig', [
'invoice' => $invoice,
'form' => $form->createView()
]);
}
try {
@@ -254,21 +253,50 @@ final class InvoiceController extends AbstractController
return $this->redirectToRoute('admin_invoice_list');
}
/**
* @Route(path="/edit/{id}", name="admin_invoice_edit", methods={"GET", "POST"})
* @Security("is_granted('access', invoice.getCustomer())")
* @Security("is_granted('create_invoice')")
*/
public function editAction(Invoice $invoice, Request $request): Response
{
$form = $this->createInvoiceEditForm($invoice);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->invoiceRepository->saveInvoice($invoice);
$this->flashSuccess('action.update.success');
} catch (Exception $ex) {
$this->flashUpdateException($ex);
}
return $this->redirectToRoute('admin_invoice_list');
}
return $this->render('invoice/invoice_edit.html.twig', [
'invoice' => $invoice,
'form' => $form->createView()
]);
}
/**
* @Route(path="/delete/{id}/{token}", name="admin_invoice_delete", methods={"GET"})
* @Security("is_granted('access', invoice.getCustomer())")
* @Security("is_granted('delete_invoice')")
*/
public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.delete', $token))) {
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('admin_invoice_list');
}
$csrfTokenManager->refreshToken($token);
$csrfTokenManager->refreshToken('invoice.status');
try {
$this->service->deleteInvoice($invoice);
$this->service->deleteInvoice($invoice, $this->dispatcher);
$this->flashSuccess('action.delete.success');
} catch (Exception $ex) {
$this->flashDeleteException($ex);
@@ -279,6 +307,8 @@ final class InvoiceController extends AbstractController
/**
* @Route(path="/download/{id}", name="admin_invoice_download", methods={"GET"})
* @Security("is_granted('access', invoice.getCustomer())")
* @Security("is_granted('create_invoice')")
*/
public function downloadAction(Invoice $invoice): Response
{
@@ -295,6 +325,7 @@ final class InvoiceController extends AbstractController
/**
* @Route(path="/show/{page}", defaults={"page": 1}, requirements={"page": "[1-9]\d*"}, name="admin_invoice_list", methods={"GET"})
* @Security("is_granted('view_invoice')")
*/
public function showInvoicesAction(Request $request, int $page): Response
{
@@ -320,11 +351,13 @@ final class InvoiceController extends AbstractController
'query' => $query,
'toolbarForm' => $form->createView(),
'download' => $invoice,
'metaColumns' => $this->findMetaColumns($query),
]);
}
/**
* @Route(path="/export", name="invoice_export", methods={"GET"})
* @Security("is_granted('view_invoice')")
*/
public function exportAction(Request $request, AnnotatedObjectExporter $exporter)
{
@@ -382,17 +415,45 @@ final class InvoiceController extends AbstractController
$invoiceDir = $projectDirectory . DIRECTORY_SEPARATOR . $dir;
}
$used = [];
foreach ($this->templateRepository->findAll() as $template) {
$used[$template->getRenderer()] = $template;
}
$event = new InvoiceDocumentsEvent($this->service->getDocuments(true));
$this->dispatcher->dispatch($event);
$documents = [];
foreach ($event->getInvoiceDocuments() as $document) {
$isUsed = \array_key_exists($document->getId(), $used);
$template = null;
if ($isUsed) {
$template = $used[$document->getId()];
}
$documents[] = [
'document' => $document,
'template' => $template,
'used' => $isUsed,
];
}
$canUpload = true;
$uploadError = null;
if (\count($documents) >= $event->getMaximumAllowedDocuments()) {
$uploadError = 'invoice_document.max_reached';
$canUpload = false;
}
if (!file_exists($invoiceDir)) {
@mkdir($invoiceDir, 0777);
}
if (!is_dir($invoiceDir)) {
$this->flashError(sprintf('Invoice directory "%s" is not existing and could not be created.', $dir));
$uploadError = 'error.directory_missing';
$canUpload = false;
} elseif (!is_writable($invoiceDir)) {
$this->flashError(sprintf('Invoice directory "%s" cannot be written.', $dir));
$uploadError = 'error.directory_protected';
$canUpload = false;
}
@@ -413,7 +474,10 @@ final class InvoiceController extends AbstractController
'Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()',
$originalFilename
);
$newFilename = $safeFilename . '.' . $uploadedFile->guessExtension();
$extension = $uploadedFile->guessExtension();
$newFilename = substr($safeFilename, 0, 20) . '.' . $extension;
try {
$uploadedFile->move($invoiceDir, $newFilename);
@@ -427,12 +491,56 @@ final class InvoiceController extends AbstractController
}
return $this->render('invoice/document_upload.html.twig', [
'error_replacer' => ['%max%' => $event->getMaximumAllowedDocuments(), '%dir%' => $dir],
'upload_error' => $uploadError,
'can_upload' => $canUpload,
'form' => $form->createView(),
'documents' => $this->service->getDocuments(true),
'documents' => $documents,
'baseDirectory' => $projectDirectory . DIRECTORY_SEPARATOR,
]);
}
/**
* @Route(path="/document/{id}/delete/{token}", name="invoice_document_delete", methods={"GET", "POST"})
* @Security("is_granted('manage_invoice_template')")
*/
public function deleteDocument(string $id, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceDocumentRepository $documentRepository): Response
{
$document = $documentRepository->findByName($id);
if ($document === null) {
throw $this->createNotFoundException();
}
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.delete_document', $token))) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('admin_invoice_document_upload');
}
$csrfTokenManager->refreshToken('invoice.delete_document');
foreach ($documentRepository->findBuiltIn() as $doc) {
if ($doc->getId() === $id) {
throw new \Exception('Document is built-in and cannot be deleted');
}
}
foreach ($this->templateRepository->findAll() as $template) {
if ($template->getRenderer() === $id) {
throw new \Exception('Document is used and cannot be deleted');
}
}
try {
$documentRepository->remove($document);
$this->flashSuccess('action.delete.success');
} catch (Exception $ex) {
$this->flashDeleteException($ex);
}
return $this->redirectToRoute('admin_invoice_document_upload');
}
/**
* @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"})
@@ -462,7 +570,7 @@ final class InvoiceController extends AbstractController
return $this->redirectToRoute('admin_invoice_template');
}
$csrfTokenManager->refreshToken($token);
$csrfTokenManager->refreshToken('invoice.delete_template');
try {
$this->templateRepository->removeTemplate($template);
@@ -474,9 +582,65 @@ final class InvoiceController extends AbstractController
return $this->redirectToRoute('admin_invoice_template');
}
private function getDefaultQuery(): InvoiceQuery
{
$factory = $this->getDateTimeFactory();
$begin = $factory->getStartOfMonth();
$end = $factory->getEndOfMonth();
$query = new InvoiceQuery();
$query->setBegin($begin);
$query->setEnd($end);
// limit access to data from teams
$query->setCurrentUser($this->getUser());
if (!$this->isGranted('view_other_timesheet')) {
// limit access to own data
$query->setUser($this->getUser());
}
return $query;
}
private function renderInvoice(InvoiceQuery $query, Request $request)
{
// use the current request locale as fallback, if no translation was configured
if (null !== $query->getTemplate() && null === $query->getTemplate()->getLanguage()) {
$query->getTemplate()->setLanguage($request->getLocale());
}
try {
$invoices = $this->service->createInvoices($query, $this->dispatcher);
$this->flashSuccess('action.update.success');
if (\count($invoices) === 1) {
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoices[0]->getId()]);
} elseif (\count($invoices) > 1) {
$this->dispatcher->dispatch(new InvoiceCreatedMultipleEvent($invoices));
}
return $this->redirectToRoute('admin_invoice_list');
} catch (Exception $ex) {
$this->flashUpdateException($ex);
}
return $this->redirectToRoute('invoice');
}
private function flashFormError(FormInterface $form): void
{
$err = '';
foreach ($form->getErrors(true, true) as $error) {
$err .= PHP_EOL . '[' . $error->getOrigin()->getName() . '] ' . $error->getMessage();
}
$this->flashError('action.update.error', ['%reason%' => $err]);
}
private function renderTemplateForm(InvoiceTemplate $template, Request $request): Response
{
$editForm = $this->createEditForm($template);
$editForm = $this->createTemplateEditForm($template);
$editForm->handleRequest($request);
@@ -524,7 +688,7 @@ final class InvoiceController extends AbstractController
]);
}
private function createEditForm(InvoiceTemplate $template): FormInterface
private function createTemplateEditForm(InvoiceTemplate $template): FormInterface
{
if ($template->getId() === null) {
$url = $this->generateUrl('admin_invoice_template_create');
@@ -538,16 +702,25 @@ final class InvoiceController extends AbstractController
]);
}
private function createPaymentDateForm(Invoice $invoice, string $status): FormInterface
/**
* @param InvoiceArchiveQuery $query
* @return MetaTableTypeInterface[]
*/
private function findMetaColumns(InvoiceArchiveQuery $query): array
{
if (null === $invoice->getPaymentDate()) {
$invoice->setPaymentDate($this->getDateTimeFactory()->createDateTime());
}
$event = new InvoiceMetaDisplayEvent($query, InvoiceMetaDisplayEvent::INVOICE);
$this->dispatcher->dispatch($event);
$url = $this->generateUrl('admin_invoice_status', ['id' => $invoice->getId(), 'status' => $status]);
return $event->getFields();
}
return $this->createForm(InvoicePaymentDateForm::class, $invoice, [
'action' => $url,
private function createInvoiceEditForm(Invoice $invoice): FormInterface
{
$event = new InvoiceMetaDefinitionEvent($invoice);
$this->dispatcher->dispatch($event);
return $this->createForm(InvoiceEditForm::class, $invoice, [
'action' => $this->generateUrl('admin_invoice_edit', ['id' => $invoice->getId()]),
'method' => 'POST',
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
]);

View File

@@ -9,7 +9,6 @@
namespace App\Controller;
use App\Entity\TeamMember;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Event\PrepareUserEvent;
@@ -19,7 +18,9 @@ use App\Form\UserPasswordType;
use App\Form\UserPreferencesForm;
use App\Form\UserRolesType;
use App\Form\UserTeamsType;
use App\Repository\TeamRepository;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
use App\Timesheet\TimesheetStatisticService;
use App\User\UserService;
use Doctrine\Common\Collections\ArrayCollection;
@@ -78,15 +79,13 @@ final class ProfileController extends AbstractController
* @Route(path="/{username}/edit", name="user_profile_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', profile)")
*/
public function editAction(User $profile, Request $request): Response
public function editAction(User $profile, Request $request, UserRepository $userRepository): Response
{
$form = $this->createEditForm($profile);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($profile);
$entityManager->flush();
$userRepository->saveUser($profile);
$this->flashSuccess('action.update.success');
@@ -152,7 +151,7 @@ final class ProfileController extends AbstractController
* @Route(path="/{username}/roles", name="user_profile_roles", methods={"GET", "POST"})
* @Security("is_granted('roles', profile)")
*/
public function rolesAction(User $profile, Request $request): Response
public function rolesAction(User $profile, Request $request, UserRepository $userRepository): Response
{
$isSuperAdmin = $profile->isSuperAdmin();
@@ -166,9 +165,7 @@ final class ProfileController extends AbstractController
$profile->setSuperAdmin(true);
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($profile);
$entityManager->flush();
$userRepository->saveUser($profile);
$this->flashSuccess('action.update.success');
@@ -186,7 +183,7 @@ final class ProfileController extends AbstractController
* @Route(path="/{username}/teams", name="user_profile_teams", methods={"GET", "POST"})
* @Security("is_granted('teams', profile)")
*/
public function teamsAction(User $profile, Request $request, UserService $service): Response
public function teamsAction(User $profile, Request $request, UserRepository $userRepository, TeamRepository $teamRepository): Response
{
$originalMembers = new ArrayCollection();
foreach ($profile->getMemberships() as $member) {
@@ -197,18 +194,15 @@ final class ProfileController extends AbstractController
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
/** @var TeamMember $member */
foreach ($originalMembers as $member) {
if (!$profile->hasMembership($member)) {
$member->getTeam()->removeMember($member);
$entityManager->remove($profile);
$member->setTeam(null);
$member->setUser(null);
$teamRepository->removeTeamMember($member);
}
}
$entityManager->persist($profile);
$entityManager->flush();
$userRepository->saveUser($profile);
$this->flashSuccess('action.update.success');

View File

@@ -17,6 +17,7 @@ use App\Entity\ProjectComment;
use App\Entity\ProjectRate;
use App\Entity\Rate;
use App\Entity\Team;
use App\Event\ProjectDetailControllerEvent;
use App\Event\ProjectMetaDefinitionEvent;
use App\Event\ProjectMetaDisplayEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
@@ -194,7 +195,7 @@ final class ProjectController extends AbstractController
return $this->redirectToRoute('project_details', ['id' => $projectId]);
}
$csrfTokenManager->refreshToken($token);
$csrfTokenManager->refreshToken('project.delete_comment');
try {
$this->repository->deleteComment($comment);
@@ -241,7 +242,7 @@ final class ProjectController extends AbstractController
return $this->redirectToRoute('project_details', ['id' => $projectId]);
}
$csrfTokenManager->refreshToken($token);
$csrfTokenManager->refreshToken('project.pin_comment');
$comment->setPinned(!$comment->isPinned());
try {
@@ -348,6 +349,11 @@ final class ProjectController extends AbstractController
$teams = $project->getTeams();
}
// additional boxes by plugins
$event = new ProjectDetailControllerEvent($project);
$this->dispatcher->dispatch($event);
$boxes = $event->getController();
return $this->render('project/details.html.twig', [
'project' => $project,
'comments' => $comments,
@@ -358,6 +364,7 @@ final class ProjectController extends AbstractController
'teams' => $teams,
'rates' => $rates,
'now' => $now,
'boxes' => $boxes
]);
}
@@ -421,13 +428,23 @@ final class ProjectController extends AbstractController
}
/**
* @Route(path="/{id}/duplicate", name="admin_project_duplicate", methods={"GET", "POST"})
* @Route(path="/{id}/duplicate/{token}", name="admin_project_duplicate", methods={"GET", "POST"})
* @Security("is_granted('edit', project)")
*/
public function duplicateAction(Project $project, Request $request, ProjectDuplicationService $projectDuplicationService)
public function duplicateAction(Project $project, string $token, ProjectDuplicationService $projectDuplicationService, CsrfTokenManagerInterface $csrfTokenManager)
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('project.duplicate', $token))) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
}
$csrfTokenManager->refreshToken('project.duplicate');
$newProject = $projectDuplicationService->duplicate($project, $project->getName() . ' [COPY]');
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('project_details', ['id' => $newProject->getId()]);
}

View File

@@ -25,7 +25,7 @@ use Symfony\Component\Routing\Annotation\Route;
* Controller used to enter times in weekly form.
*
* @Route(path="/quick_entry")
* @Security("is_granted('weekly_own_timesheet') and is_granted('edit_own_timesheet')")
* @Security("is_granted('quick-entry')")
*/
class QuickEntryController extends AbstractController
{
@@ -45,14 +45,6 @@ class QuickEntryController extends AbstractController
*/
public function quickEntry(Request $request, ?string $begin = null)
{
$mode = $this->timesheetService->getActiveTrackingMode();
if (!$mode->canEditDuration() && !$mode->canEditEnd()) {
$this->flashError('Not allowed');
return $this->redirectToRoute('homepage');
}
$factory = $this->getDateTimeFactory();
if ($begin === null) {
$begin = $factory->createDateTime();
@@ -106,7 +98,8 @@ class QuickEntryController extends AbstractController
ksort($rows);
// attach recent activities
$timesheets = $this->repository->getRecentActivities($this->getUser(), null, 5);
$amount = $this->configuration->getQuickEntriesRecentAmount();
$timesheets = $this->repository->getRecentActivities($this->getUser(), null, $amount);
foreach ($timesheets as $timesheet) {
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
if (\array_key_exists($id, $rows)) {

View File

@@ -0,0 +1,125 @@
<?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\Reporting;
use App\Controller\AbstractController;
use App\Entity\User;
use App\Model\DailyStatistic;
use App\Model\DateStatisticInterface;
use App\Model\Statistic\StatisticDate;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRepository;
use App\Timesheet\TimesheetStatisticService;
use DateTime;
abstract class AbstractUserReportController extends AbstractController
{
protected $statisticService;
private $projectRepository;
private $activityRepository;
public function __construct(TimesheetStatisticService $statisticService, ProjectRepository $projectRepository, ActivityRepository $activityRepository)
{
$this->statisticService = $statisticService;
$this->projectRepository = $projectRepository;
$this->activityRepository = $activityRepository;
}
protected function canSelectUser(): bool
{
// also found in App\EventSubscriber\Actions\UserSubscriber
if (!$this->isGranted('view_other_timesheet') || !$this->isGranted('view_other_reporting')) {
return false;
}
return true;
}
protected function getStatisticDataRaw(DateTime $begin, DateTime $end, User $user): array
{
return $this->statisticService->getDailyStatisticsGrouped($begin, $end, [$user]);
}
protected function createStatisticModel(DateTime $begin, DateTime $end, User $user): DateStatisticInterface
{
return new DailyStatistic($begin, $end, $user);
}
protected function prepareReport(DateTime $begin, DateTime $end, User $user): array
{
$data = $this->getStatisticDataRaw($begin, $end, $user);
$data = array_pop($data);
$projectIds = [];
$activityIds = [];
foreach ($data as $projectId => $projectValues) {
$projectIds[$projectId] = $projectId;
$dailyProjectStatistic = $this->createStatisticModel($begin, $end, $user);
foreach ($projectValues['activities'] as $activityId => $activityValues) {
$activityIds[$activityId] = $activityId;
if (!isset($data[$projectId]['duration'])) {
$data[$projectId]['duration'] = 0;
}
if (!isset($data[$projectId]['rate'])) {
$data[$projectId]['rate'] = 0.0;
}
if (!isset($data[$projectId]['internalRate'])) {
$data[$projectId]['internalRate'] = 0.0;
}
if (!isset($data[$projectId]['activities'][$activityId]['duration'])) {
$data[$projectId]['activities'][$activityId]['duration'] = 0;
}
if (!isset($data[$projectId]['activities'][$activityId]['rate'])) {
$data[$projectId]['activities'][$activityId]['rate'] = 0.0;
}
if (!isset($data[$projectId]['activities'][$activityId]['internalRate'])) {
$data[$projectId]['activities'][$activityId]['internalRate'] = 0.0;
}
/** @var StatisticDate $date */
foreach ($activityValues['data']->getData() as $date) {
$statisticDate = $dailyProjectStatistic->getByDateTime($date->getDate());
if ($statisticDate === null) {
// this should not happen, but sometimes it does ...
continue;
}
$statisticDate->setTotalDuration($statisticDate->getTotalDuration() + $date->getTotalDuration());
$statisticDate->setTotalRate($statisticDate->getTotalRate() + $date->getTotalRate());
$statisticDate->setTotalInternalRate($statisticDate->getTotalInternalRate() + $date->getTotalInternalRate());
$data[$projectId]['duration'] = $data[$projectId]['duration'] + $date->getTotalDuration();
$data[$projectId]['rate'] = $data[$projectId]['rate'] + $date->getTotalRate();
$data[$projectId]['internalRate'] = $data[$projectId]['internalRate'] + $date->getTotalInternalRate();
$data[$projectId]['activities'][$activityId]['duration'] = $data[$projectId]['activities'][$activityId]['duration'] + $date->getTotalDuration();
$data[$projectId]['activities'][$activityId]['rate'] = $data[$projectId]['activities'][$activityId]['rate'] + $date->getTotalRate();
$data[$projectId]['activities'][$activityId]['internalRate'] = $data[$projectId]['activities'][$activityId]['internalRate'] + $date->getTotalInternalRate();
}
}
$data[$projectId]['data'] = $dailyProjectStatistic;
}
$activities = $this->activityRepository->findByIds($activityIds);
foreach ($activities as $activity) {
$activityIds[$activity->getId()] = $activity;
}
foreach ($data as $projectId => $projectValues) {
foreach ($projectValues['activities'] as $activityId => $activityValues) {
$data[$projectId]['activities'][$activityId]['activity'] = $activityIds[$activityId];
}
}
$projects = $this->projectRepository->findByIds($projectIds);
foreach ($projects as $project) {
$data[$project->getId()]['project'] = $project;
}
return $data;
}
}

View File

@@ -0,0 +1,111 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller\Reporting;
use App\Controller\AbstractController;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Reporting\CustomerMonthlyProjects\CustomerMonthlyProjects;
use App\Reporting\CustomerMonthlyProjects\CustomerMonthlyProjectsForm;
use App\Repository\Query\UserQuery;
use App\Repository\UserRepository;
use App\Timesheet\TimesheetStatisticService;
use PhpOffice\PhpSpreadsheet\Reader\Html;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/reporting/customer/monthly_projects")
* @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')")
*/
final class CustomerMonthlyProjectsController extends AbstractController
{
/**
* @Route(path="/view", name="report_customer_monthly_projects", methods={"GET","POST"})
*/
public function report(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
{
return $this->render(
'reporting/customer/monthly_projects.html.twig',
$this->getData($request, $statisticService, $userRepository)
);
}
/**
* @Route(path="/export", name="report_customer_monthly_projects_export", methods={"GET","POST"})
*/
public function export(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
{
$data = $this->getData($request, $statisticService, $userRepository);
$content = $this->render('reporting/customer/monthly_projects_export.html.twig', $data)->getContent();
$reader = new Html();
$spreadsheet = $reader->loadFromString($content);
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-users-monthly');
return $writer->getFileResponse($spreadsheet);
}
private function getData(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): array
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory();
$query = new UserQuery();
$query->setCurrentUser($currentUser);
$allUsers = $userRepository->getUsersForQuery($query);
$values = new CustomerMonthlyProjects();
$values->setDate($dateTimeFactory->getStartOfMonth());
$form = $this->createFormForGetRequest(CustomerMonthlyProjectsForm::class, $values, [
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
]);
$form->submit($request->query->all(), false);
if ($form->isSubmitted() && !$form->isValid()) {
$values->setDate($dateTimeFactory->getStartOfMonth());
}
if ($values->getDate() === null) {
$values->setDate($dateTimeFactory->getStartOfMonth());
}
$start = $values->getDate();
$start = $dateTimeFactory->getStartOfMonth($start);
$end = $dateTimeFactory->getEndOfMonth($start);
$previous = clone $start;
$previous->modify('-1 month');
$next = clone $start;
$next->modify('+1 month');
$stats = $statisticService->getGroupedByCustomerProjectActivityUser($start, $end, $allUsers);
return [
'dataType' => $values->getSumType(),
'report_title' => 'report_customer_monthly_projects',
'export_route' => 'report_customer_monthly_projects_export',
'form' => $form->createView(),
'current' => $start,
'next' => $next,
'previous' => $previous,
'decimal' => $values->isDecimal(),
'stats' => $stats,
];
}
}

View File

@@ -1,234 +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\Controller\Reporting;
use App\Controller\AbstractController;
use App\Entity\User;
use App\Model\DailyStatistic;
use App\Model\Statistic\StatisticDate;
use App\Reporting\MonthByUser;
use App\Reporting\MonthByUserForm;
use App\Reporting\WeekByUser;
use App\Reporting\WeekByUserForm;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRepository;
use App\Timesheet\TimesheetStatisticService;
use DateTime;
use Exception;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
/**
* @Route(path="/reporting")
* @Security("is_granted('view_reporting')")
*/
final class ReportByUserController extends AbstractController
{
private $statisticService;
private $projectRepository;
private $activityRepository;
public function __construct(TimesheetStatisticService $statisticService, ProjectRepository $projectRepository, ActivityRepository $activityRepository)
{
$this->statisticService = $statisticService;
$this->projectRepository = $projectRepository;
$this->activityRepository = $activityRepository;
}
private function canSelectUser(): bool
{
// also found in App\EventSubscriber\Actions\UserSubscriber
if (!$this->isGranted('view_other_timesheet') || !$this->isGranted('view_other_reporting')) {
return false;
}
return true;
}
/**
* @Route(path="/month_by_user", name="report_user_month", methods={"GET","POST"})
*
* @param Request $request
* @return Response
* @throws Exception
*/
public function monthByUser(Request $request): Response
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
$canChangeUser = $this->canSelectUser();
$values = new MonthByUser();
$values->setUser($currentUser);
$values->setDate($dateTimeFactory->getStartOfMonth());
$form = $this->createForm(MonthByUserForm::class, $values, [
'include_user' => $canChangeUser,
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
]);
$form->submit($request->query->all(), false);
if ($values->getUser() === null) {
$values->setUser($currentUser);
}
if ($currentUser !== $values->getUser() && !$canChangeUser) {
throw new AccessDeniedException('User is not allowed to see other users timesheet');
}
if ($values->getDate() === null) {
$values->setDate($dateTimeFactory->getStartOfMonth());
}
$start = $values->getDate();
$start->modify('first day of 00:00:00');
$end = clone $start;
$end->modify('last day of 23:59:59');
$selectedUser = $values->getUser();
$previousMonth = clone $start;
$previousMonth->modify('-1 month');
$nextMonth = clone $start;
$nextMonth->modify('+1 month');
$data = $this->prepareReport($start, $end, $selectedUser);
return $this->render('reporting/report_by_user.html.twig', [
'report_title' => 'report_user_month',
'box_id' => 'user-month-reporting-box',
'form' => $form->createView(),
'rows' => $data,
'days' => new DailyStatistic($start, $end, $selectedUser),
'user' => $selectedUser,
'current' => $start,
'next' => $nextMonth,
'previous' => $previousMonth,
]);
}
/**
* @Route(path="/week_by_user", name="report_user_week", methods={"GET","POST"})
*
* @param Request $request
* @return Response
* @throws Exception
*/
public function weekByUser(Request $request): Response
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
$canChangeUser = $this->canSelectUser();
$values = new WeekByUser();
$values->setUser($currentUser);
$values->setDate($dateTimeFactory->getStartOfWeek());
$form = $this->createForm(WeekByUserForm::class, $values, [
'include_user' => $canChangeUser,
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
]);
$form->submit($request->query->all(), false);
if ($values->getUser() === null) {
$values->setUser($currentUser);
}
if ($currentUser !== $values->getUser() && !$canChangeUser) {
throw new AccessDeniedException('User is not allowed to see other users timesheet');
}
if ($values->getDate() === null) {
$values->setDate($dateTimeFactory->getStartOfWeek());
}
$start = $dateTimeFactory->getStartOfWeek($values->getDate());
$end = $dateTimeFactory->getEndOfWeek($values->getDate());
$selectedUser = $values->getUser();
$previous = clone $start;
$previous->modify('-1 week');
$next = clone $start;
$next->modify('+1 week');
$data = $this->prepareReport($start, $end, $selectedUser);
return $this->render('reporting/report_by_user.html.twig', [
'report_title' => 'report_user_week',
'box_id' => 'user-week-reporting-box',
'form' => $form->createView(),
'days' => new DailyStatistic($start, $end, $selectedUser),
'rows' => $data,
'user' => $selectedUser,
'current' => $start,
'next' => $next,
'previous' => $previous,
]);
}
private function prepareReport(DateTime $begin, DateTime $end, User $user): array
{
$data = $this->statisticService->getDailyStatisticsGrouped($begin, $end, [$user]);
$data = array_pop($data);
$projectIds = [];
$activityIds = [];
foreach ($data as $projectId => $projectValues) {
$projectIds[$projectId] = $projectId;
$dailyProjectStatistic = new DailyStatistic($begin, $end, $user);
foreach ($projectValues['activities'] as $activityId => $activityValues) {
$activityIds[$activityId] = $activityId;
if (!isset($data[$projectId]['duration'])) {
$data[$projectId]['duration'] = 0;
}
if (!isset($data[$projectId]['activities'][$activityId]['duration'])) {
$data[$projectId]['activities'][$activityId]['duration'] = 0;
}
/** @var StatisticDate $day */
foreach ($activityValues['days']->getDays() as $day) {
$statDay = $dailyProjectStatistic->getDayByDateTime($day->getDate());
$statDay->setTotalDuration($statDay->getTotalDuration() + $day->getDuration());
$data[$projectId]['duration'] = $data[$projectId]['duration'] + $day->getDuration();
$data[$projectId]['activities'][$activityId]['duration'] = $data[$projectId]['activities'][$activityId]['duration'] + $day->getDuration();
}
}
$data[$projectId]['days'] = $dailyProjectStatistic;
}
$activities = $this->activityRepository->findByIds($activityIds);
foreach ($activities as $activity) {
$activityIds[$activity->getId()] = $activity;
}
foreach ($data as $projectId => $projectValues) {
foreach ($projectValues['activities'] as $activityId => $activityValues) {
$data[$projectId]['activities'][$activityId]['activity'] = $activityIds[$activityId];
}
}
$projects = $this->projectRepository->findByIds($projectIds);
foreach ($projects as $project) {
$data[$project->getId()]['project'] = $project;
}
return $data;
}
}

View File

@@ -1,241 +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\Controller\Reporting;
use App\Configuration\SystemConfiguration;
use App\Controller\AbstractController;
use App\Model\DailyStatistic;
use App\Model\MonthlyStatistic;
use App\Reporting\MonthlyUserList;
use App\Reporting\MonthlyUserListForm;
use App\Reporting\WeeklyUserList;
use App\Reporting\WeeklyUserListForm;
use App\Reporting\YearlyUserList;
use App\Reporting\YearlyUserListForm;
use App\Repository\Query\UserQuery;
use App\Repository\UserRepository;
use App\Timesheet\TimesheetStatisticService;
use Exception;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/reporting")
* @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')")
*/
final class ReportUsersListController extends AbstractController
{
private $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
/**
* @Route(path="/yearly_users_list", name="report_yearly_users", methods={"GET","POST"})
*
* @param Request $request
* @return Response
* @throws Exception
*/
public function yearlyUsersList(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService): Response
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory();
$query = new UserQuery();
$query->setCurrentUser($currentUser);
$allUsers = $this->userRepository->getUsersForQuery($query);
$defaultDate = $dateTimeFactory->createDateTime('01 january this year 00:00:00');
if (null !== ($financialYear = $systemConfiguration->getFinancialYearStart())) {
$defaultDate = $this->getDateTimeFactory()->createStartOfFinancialYear($financialYear);
}
$values = new YearlyUserList();
$values->setDate(clone $defaultDate);
$form = $this->createForm(YearlyUserListForm::class, $values, [
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
]);
$form->submit($request->query->all(), false);
if ($form->isSubmitted() && !$form->isValid()) {
$values->setDate(clone $defaultDate);
}
if ($values->getDate() === null) {
$values->setDate(clone $defaultDate);
}
$start = $values->getDate();
// there is a potential edge case bug for financial years:
// the last month will be skipped, if the financial year started on a different day than the first
$end = $dateTimeFactory->createEndOfFinancialYear($start);
$monthStats = [];
$hasData = true;
if (!empty($allUsers)) {
$monthStats = $statisticService->getMonthlyStats($start, $end, $allUsers);
}
if (empty($monthStats)) {
$monthStats = [new MonthlyStatistic($start, $end, $currentUser)];
$hasData = false;
}
return $this->render('reporting/report_user_list_monthly.html.twig', [
'report_title' => 'report_yearly_users',
'box_id' => 'yearly-user-list-reporting-box',
'form' => $form->createView(),
'stats' => $monthStats,
'hasData' => $hasData,
]);
}
/**
* @Route(path="/monthly_users_list", name="report_monthly_users", methods={"GET","POST"})
*/
public function monthlyUsersList(Request $request, TimesheetStatisticService $statisticService): Response
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory();
$query = new UserQuery();
$query->setCurrentUser($currentUser);
$allUsers = $this->userRepository->getUsersForQuery($query);
$values = new MonthlyUserList();
$values->setDate($dateTimeFactory->getStartOfMonth());
$form = $this->createForm(MonthlyUserListForm::class, $values, [
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
]);
$form->submit($request->query->all(), false);
if ($form->isSubmitted() && !$form->isValid()) {
$values->setDate($dateTimeFactory->getStartOfMonth());
}
if ($values->getDate() === null) {
$values->setDate($dateTimeFactory->getStartOfMonth());
}
$start = $values->getDate();
$start->modify('first day of 00:00:00');
$end = clone $start;
$end->modify('last day of 23:59:59');
$previous = clone $start;
$previous->modify('-1 month');
$next = clone $start;
$next->modify('+1 month');
$dayStats = [];
$hasData = true;
if (!empty($allUsers)) {
$dayStats = $statisticService->getDailyStatistics($start, $end, $allUsers);
}
if (empty($dayStats)) {
$dayStats = [new DailyStatistic($start, $end, $currentUser)];
$hasData = false;
}
return $this->render('reporting/report_user_list.html.twig', [
'report_title' => 'report_monthly_users',
'box_id' => 'monthly-user-list-reporting-box',
'form' => $form->createView(),
'current' => $start,
'next' => $next,
'previous' => $previous,
'subReportDate' => $values->getDate(),
'subReportRoute' => 'report_user_month',
'stats' => $dayStats,
'hasData' => $hasData,
]);
}
/**
* @Route(path="/weekly_users_list", name="report_weekly_users", methods={"GET","POST"})
*/
public function weeklyUsersList(Request $request, TimesheetStatisticService $statisticService): Response
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory();
$query = new UserQuery();
$query->setCurrentUser($currentUser);
$allUsers = $this->userRepository->getUsersForQuery($query);
$values = new WeeklyUserList();
$values->setDate($dateTimeFactory->getStartOfWeek());
$form = $this->createForm(WeeklyUserListForm::class, $values, [
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
]);
$form->submit($request->query->all(), false);
if ($form->isSubmitted() && !$form->isValid()) {
$values->setDate($dateTimeFactory->getStartOfWeek());
}
if ($values->getDate() === null) {
$values->setDate($dateTimeFactory->getStartOfWeek());
}
$start = $dateTimeFactory->getStartOfWeek($values->getDate());
$end = $dateTimeFactory->getEndOfWeek($values->getDate());
$previous = clone $start;
$previous->modify('-1 week');
$next = clone $start;
$next->modify('+1 week');
$dayStats = [];
$hasData = true;
if (!empty($allUsers)) {
$dayStats = $statisticService->getDailyStatistics($start, $end, $allUsers);
}
if (empty($dayStats)) {
$dayStats = [new DailyStatistic($start, $end, $currentUser)];
$hasData = false;
}
return $this->render('reporting/report_user_list.html.twig', [
'report_title' => 'report_weekly_users',
'box_id' => 'weekly-user-list-reporting-box',
'form' => $form->createView(),
'current' => $start,
'next' => $next,
'previous' => $previous,
'subReportDate' => $values->getDate(),
'subReportRoute' => 'report_user_week',
'stats' => $dayStats,
'hasData' => $hasData,
]);
}
}

View File

@@ -0,0 +1,129 @@
<?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\Reporting;
use App\Controller\AbstractController;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Model\DailyStatistic;
use App\Reporting\MonthlyUserList;
use App\Reporting\MonthlyUserListForm;
use App\Repository\Query\UserQuery;
use App\Repository\UserRepository;
use App\Timesheet\TimesheetStatisticService;
use PhpOffice\PhpSpreadsheet\Reader\Html;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/reporting/users")
* @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')")
*/
final class ReportUsersMonthController extends AbstractController
{
/**
* @Route(path="/month", name="report_monthly_users", methods={"GET","POST"})
*/
public function report(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
{
return $this->render(
'reporting/report_user_list.html.twig',
$this->getData($request, $statisticService, $userRepository)
);
}
/**
* @Route(path="/month_export", name="report_monthly_users_export", methods={"GET","POST"})
*/
public function export(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
{
$data = $this->getData($request, $statisticService, $userRepository);
$content = $this->container->get('twig')->render('reporting/report_user_list_export.html.twig', $data);
$reader = new Html();
$spreadsheet = $reader->loadFromString($content);
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-users-monthly');
return $writer->getFileResponse($spreadsheet);
}
private function getData(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): array
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory();
$query = new UserQuery();
$query->setCurrentUser($currentUser);
$allUsers = $userRepository->getUsersForQuery($query);
$values = new MonthlyUserList();
$values->setDate($dateTimeFactory->getStartOfMonth());
$form = $this->createForm(MonthlyUserListForm::class, $values, [
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
]);
$form->submit($request->query->all(), false);
if ($form->isSubmitted() && !$form->isValid()) {
$values->setDate($dateTimeFactory->getStartOfMonth());
}
if ($values->getDate() === null) {
$values->setDate($dateTimeFactory->getStartOfMonth());
}
$start = $values->getDate();
$start->modify('first day of 00:00:00');
$end = clone $start;
$end->modify('last day of 23:59:59');
$previous = clone $start;
$previous->modify('-1 month');
$next = clone $start;
$next->modify('+1 month');
$dayStats = [];
$hasData = true;
if (!empty($allUsers)) {
$dayStats = $statisticService->getDailyStatistics($start, $end, $allUsers);
}
if (empty($dayStats)) {
$dayStats = [new DailyStatistic($start, $end, $currentUser)];
$hasData = false;
}
return [
'period_attribute' => 'days',
'dataType' => $values->getSumType(),
'report_title' => 'report_monthly_users',
'box_id' => 'monthly-user-list-reporting-box',
'export_route' => 'report_monthly_users_export',
'form' => $form->createView(),
'current' => $start,
'next' => $next,
'previous' => $previous,
'decimal' => $values->isDecimal(),
'subReportDate' => $values->getDate(),
'subReportRoute' => 'report_user_month',
'stats' => $dayStats,
'hasData' => $hasData,
];
}
}

View File

@@ -0,0 +1,126 @@
<?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\Reporting;
use App\Controller\AbstractController;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Model\DailyStatistic;
use App\Reporting\WeeklyUserList;
use App\Reporting\WeeklyUserListForm;
use App\Repository\Query\UserQuery;
use App\Repository\UserRepository;
use App\Timesheet\TimesheetStatisticService;
use PhpOffice\PhpSpreadsheet\Reader\Html;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/reporting/users")
* @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')")
*/
final class ReportUsersWeekController extends AbstractController
{
/**
* @Route(path="/week", name="report_weekly_users", methods={"GET","POST"})
*/
public function report(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
{
return $this->render(
'reporting/report_user_list.html.twig',
$this->getData($request, $statisticService, $userRepository)
);
}
/**
* @Route(path="/week_export", name="report_weekly_users_export", methods={"GET","POST"})
*/
public function export(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
{
$data = $this->getData($request, $statisticService, $userRepository);
$content = $this->container->get('twig')->render('reporting/report_user_list_export.html.twig', $data);
$reader = new Html();
$spreadsheet = $reader->loadFromString($content);
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-users-weekly');
return $writer->getFileResponse($spreadsheet);
}
private function getData(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): array
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory();
$query = new UserQuery();
$query->setCurrentUser($currentUser);
$allUsers = $userRepository->getUsersForQuery($query);
$values = new WeeklyUserList();
$values->setDate($dateTimeFactory->getStartOfWeek());
$form = $this->createForm(WeeklyUserListForm::class, $values, [
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
]);
$form->submit($request->query->all(), false);
if ($form->isSubmitted() && !$form->isValid()) {
$values->setDate($dateTimeFactory->getStartOfWeek());
}
if ($values->getDate() === null) {
$values->setDate($dateTimeFactory->getStartOfWeek());
}
$start = $dateTimeFactory->getStartOfWeek($values->getDate());
$end = $dateTimeFactory->getEndOfWeek($values->getDate());
$previous = clone $start;
$previous->modify('-1 week');
$next = clone $start;
$next->modify('+1 week');
$dayStats = [];
$hasData = true;
if (!empty($allUsers)) {
$dayStats = $statisticService->getDailyStatistics($start, $end, $allUsers);
}
if (empty($dayStats)) {
$dayStats = [new DailyStatistic($start, $end, $currentUser)];
$hasData = false;
}
return [
'period_attribute' => 'days',
'dataType' => $values->getSumType(),
'report_title' => 'report_weekly_users',
'box_id' => 'weekly-user-list-reporting-box',
'export_route' => 'report_weekly_users_export',
'form' => $form->createView(),
'current' => $start,
'next' => $next,
'previous' => $previous,
'decimal' => $values->isDecimal(),
'subReportDate' => $values->getDate(),
'subReportRoute' => 'report_user_week',
'stats' => $dayStats,
'hasData' => $hasData,
];
}
}

View File

@@ -0,0 +1,133 @@
<?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\Reporting;
use App\Configuration\SystemConfiguration;
use App\Controller\AbstractController;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Model\MonthlyStatistic;
use App\Reporting\YearlyUserList;
use App\Reporting\YearlyUserListForm;
use App\Repository\Query\UserQuery;
use App\Repository\UserRepository;
use App\Timesheet\TimesheetStatisticService;
use Exception;
use PhpOffice\PhpSpreadsheet\Reader\Html;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/reporting/users")
* @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')")
*/
final class ReportUsersYearController extends AbstractController
{
/**
* @Route(path="/year", name="report_yearly_users", methods={"GET","POST"})
*
* @param Request $request
* @return Response
* @throws Exception
*/
public function report(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
{
return $this->render(
'reporting/report_user_list_monthly.html.twig',
$this->getData($request, $systemConfiguration, $statisticService, $userRepository)
);
}
/**
* @Route(path="/year_export", name="report_yearly_users_export", methods={"GET","POST"})
*
* @param Request $request
* @return Response
* @throws Exception
*/
public function export(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
{
$data = $this->getData($request, $systemConfiguration, $statisticService, $userRepository);
$content = $this->container->get('twig')->render('reporting/report_user_list_monthly_export.html.twig', $data);
$reader = new Html();
$spreadsheet = $reader->loadFromString($content);
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-users-yearly');
return $writer->getFileResponse($spreadsheet);
}
private function getData(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService, UserRepository $userRepository): array
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory();
$query = new UserQuery();
$query->setCurrentUser($currentUser);
$allUsers = $userRepository->getUsersForQuery($query);
$defaultDate = $dateTimeFactory->createStartOfYear();
if (null !== ($financialYear = $systemConfiguration->getFinancialYearStart())) {
$defaultDate = $this->getDateTimeFactory()->createStartOfFinancialYear($financialYear);
}
$values = new YearlyUserList();
$values->setDate(clone $defaultDate);
$form = $this->createForm(YearlyUserListForm::class, $values, [
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
]);
$form->submit($request->query->all(), false);
if ($form->isSubmitted() && !$form->isValid()) {
$values->setDate(clone $defaultDate);
}
if ($values->getDate() === null) {
$values->setDate(clone $defaultDate);
}
$start = $values->getDate();
// there is a potential edge case bug for financial years:
// the last month will be skipped, if the financial year started on a different day than the first
$end = $dateTimeFactory->createEndOfFinancialYear($start);
$monthStats = [];
$hasData = true;
if (!empty($allUsers)) {
$monthStats = $statisticService->getMonthlyStats($start, $end, $allUsers);
}
if (empty($monthStats)) {
$monthStats = [new MonthlyStatistic($start, $end, $currentUser)];
$hasData = false;
}
return [
'query' => $values,
'period_attribute' => 'months',
'dataType' => $values->getSumType(),
'report_title' => 'report_yearly_users',
'box_id' => 'yearly-user-list-reporting-box',
'export_route' => 'report_yearly_users_export',
'decimal' => $values->isDecimal(),
'form' => $form->createView(),
'stats' => $monthStats,
'hasData' => $hasData,
];
}
}

View File

@@ -0,0 +1,101 @@
<?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\Reporting;
use App\Entity\User;
use App\Model\DailyStatistic;
use App\Reporting\MonthByUser;
use App\Reporting\MonthByUserForm;
use Exception;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
/**
* @Route(path="/reporting/user")
* @Security("is_granted('view_reporting')")
*/
final class UserMonthController extends AbstractUserReportController
{
/**
* @Route(path="/month", name="report_user_month", methods={"GET","POST"})
*
* @param Request $request
* @return Response
* @throws Exception
*/
public function monthByUser(Request $request): Response
{
return $this->render('reporting/report_by_user.html.twig', $this->getData($request));
}
private function getData(Request $request): array
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
$canChangeUser = $this->canSelectUser();
$values = new MonthByUser();
$values->setUser($currentUser);
$values->setDate($dateTimeFactory->getStartOfMonth());
$form = $this->createForm(MonthByUserForm::class, $values, [
'include_user' => $canChangeUser,
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
]);
$form->submit($request->query->all(), false);
if ($values->getUser() === null) {
$values->setUser($currentUser);
}
if ($currentUser !== $values->getUser() && !$canChangeUser) {
throw new AccessDeniedException('User is not allowed to see other users timesheet');
}
if ($values->getDate() === null) {
$values->setDate($dateTimeFactory->getStartOfMonth());
}
$start = $values->getDate();
$start->modify('first day of 00:00:00');
$end = clone $start;
$end->modify('last day of 23:59:59');
$selectedUser = $values->getUser();
$previousMonth = clone $start;
$previousMonth->modify('-1 month');
$nextMonth = clone $start;
$nextMonth->modify('+1 month');
$data = $this->prepareReport($start, $end, $selectedUser);
return [
'decimal' => $values->isDecimal(),
'dataType' => $values->getSumType(),
'report_title' => 'report_user_month',
'box_id' => 'user-month-reporting-box',
'form' => $form->createView(),
'rows' => $data,
'period' => new DailyStatistic($start, $end, $selectedUser),
'user' => $selectedUser,
'current' => $start,
'next' => $nextMonth,
'previous' => $previousMonth,
];
}
}

View File

@@ -0,0 +1,97 @@
<?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\Reporting;
use App\Entity\User;
use App\Model\DailyStatistic;
use App\Reporting\WeekByUser;
use App\Reporting\WeekByUserForm;
use Exception;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
/**
* @Route(path="/reporting/user")
* @Security("is_granted('view_reporting')")
*/
final class UserWeekController extends AbstractUserReportController
{
/**
* @Route(path="/week", name="report_user_week", methods={"GET","POST"})
*
* @param Request $request
* @return Response
* @throws Exception
*/
public function weekByUser(Request $request): Response
{
return $this->render('reporting/report_by_user.html.twig', $this->getData($request));
}
private function getData(Request $request): array
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
$canChangeUser = $this->canSelectUser();
$values = new WeekByUser();
$values->setUser($currentUser);
$values->setDate($dateTimeFactory->getStartOfWeek());
$form = $this->createForm(WeekByUserForm::class, $values, [
'include_user' => $canChangeUser,
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
]);
$form->submit($request->query->all(), false);
if ($values->getUser() === null) {
$values->setUser($currentUser);
}
if ($currentUser !== $values->getUser() && !$canChangeUser) {
throw new AccessDeniedException('User is not allowed to see other users timesheet');
}
if ($values->getDate() === null) {
$values->setDate($dateTimeFactory->getStartOfWeek());
}
$start = $dateTimeFactory->getStartOfWeek($values->getDate());
$end = $dateTimeFactory->getEndOfWeek($values->getDate());
$selectedUser = $values->getUser();
$previous = clone $start;
$previous->modify('-1 week');
$next = clone $start;
$next->modify('+1 week');
$data = $this->prepareReport($start, $end, $selectedUser);
return [
'decimal' => $values->isDecimal(),
'dataType' => $values->getSumType(),
'report_title' => 'report_user_week',
'box_id' => 'user-week-reporting-box',
'form' => $form->createView(),
'period' => new DailyStatistic($start, $end, $selectedUser),
'rows' => $data,
'user' => $selectedUser,
'current' => $start,
'next' => $next,
'previous' => $previous,
];
}
}

View File

@@ -0,0 +1,120 @@
<?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\Reporting;
use App\Configuration\SystemConfiguration;
use App\Entity\User;
use App\Model\DateStatisticInterface;
use App\Model\MonthlyStatistic;
use App\Reporting\YearByUser;
use App\Reporting\YearByUserForm;
use DateTime;
use Exception;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
/**
* @Route(path="/reporting/user")
* @Security("is_granted('view_reporting')")
*/
final class UserYearController extends AbstractUserReportController
{
/**
* @Route(path="/year", name="report_user_year", methods={"GET","POST"})
*
* @param Request $request
* @return Response
* @throws Exception
*/
public function yearByUser(Request $request, SystemConfiguration $systemConfiguration): Response
{
return $this->render('reporting/report_by_user_year.html.twig', $this->getData($request, $systemConfiguration));
}
private function getData(Request $request, SystemConfiguration $systemConfiguration): array
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
$canChangeUser = $this->canSelectUser();
$values = new YearByUser();
$values->setUser($currentUser);
$defaultDate = $dateTimeFactory->createStartOfYear();
if (null !== ($financialYear = $systemConfiguration->getFinancialYearStart())) {
$defaultDate = $this->getDateTimeFactory()->createStartOfFinancialYear($financialYear);
}
$values->setDate(clone $defaultDate);
$form = $this->createForm(YearByUserForm::class, $values, [
'include_user' => $canChangeUser,
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
]);
$form->submit($request->query->all(), false);
if ($values->getUser() === null) {
$values->setUser($currentUser);
}
if ($currentUser !== $values->getUser() && !$canChangeUser) {
throw new AccessDeniedException('User is not allowed to see other users timesheet');
}
if ($values->getDate() === null) {
$values->setDate(clone $defaultDate);
}
$start = $values->getDate();
// there is a potential edge case bug for financial years:
// the last month will be skipped, if the financial year started on a different day than the first
$end = $dateTimeFactory->createEndOfFinancialYear($start);
$selectedUser = $values->getUser();
$previous = clone $start;
$previous->modify('-1 year');
$next = clone $start;
$next->modify('+1 year');
$data = $this->prepareReport($start, $end, $selectedUser);
return [
'decimal' => $values->isDecimal(),
'dataType' => $values->getSumType(),
'report_title' => 'report_user_year',
'box_id' => 'user-year-reporting-box',
'form' => $form->createView(),
'period' => new MonthlyStatistic($start, $end, $selectedUser),
'rows' => $data,
'user' => $selectedUser,
'current' => $start,
'next' => $next,
'previous' => $previous,
];
}
protected function getStatisticDataRaw(DateTime $begin, DateTime $end, User $user): array
{
return $this->statisticService->getMonthlyStatisticsGrouped($begin, $end, [$user]);
}
protected function createStatisticModel(DateTime $begin, DateTime $end, User $user): DateStatisticInterface
{
return new MonthlyStatistic($begin, $end, $user);
}
}

View File

@@ -32,6 +32,10 @@ final class SecurityController extends AbstractController
*/
public function loginAction(Request $request): Response
{
if ($this->isGranted('IS_AUTHENTICATED_FULLY')) {
return $this->redirectToRoute('homepage');
}
/** @var SessionInterface $session */
$session = $request->getSession();

View File

@@ -14,12 +14,16 @@ use App\Event\SystemConfigurationEvent;
use App\Form\Model\Configuration;
use App\Form\Model\SystemConfiguration as SystemConfigurationModel;
use App\Form\SystemConfigurationForm;
use App\Form\Type\ActivityTypePatternType;
use App\Form\Type\ArrayToCommaStringType;
use App\Form\Type\CalendarTitlePatternType;
use App\Form\Type\CustomerTypePatternType;
use App\Form\Type\DatePickerType;
use App\Form\Type\DateTimeTextType;
use App\Form\Type\DayTimeType;
use App\Form\Type\LanguageType;
use App\Form\Type\MinuteIncrementType;
use App\Form\Type\ProjectTypePatternType;
use App\Form\Type\RoundingModeType;
use App\Form\Type\SkinType;
use App\Form\Type\TimezoneType;
@@ -266,8 +270,7 @@ final class SystemConfigurationController extends AbstractController
}
}
$authentication = (new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_AUTHENTICATION)
$authentication = (new SystemConfigurationModel('authentication'))
->setConfiguration([
(new Configuration())
->setName('user.login')
@@ -319,9 +322,8 @@ final class SystemConfigurationController extends AbstractController
$authentication->getConfigurationByName('user.password_reset_token_ttl')->setEnabled(false);
}
$configurationModels = [
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_TIMESHEET)
return [
(new SystemConfigurationModel('timesheet'))
->setConfiguration([
(new Configuration())
->setName('timesheet.mode')
@@ -382,13 +384,21 @@ final class SystemConfigurationController extends AbstractController
->setConstraints([
new GreaterThanOrEqual(['value' => 0])
]),
(new Configuration())
->setName('defaults.timesheet.billable')
->setType(YesNoType::class)
->setOptions(['help' => 'default_value_new', 'label' => 'label.billable']),
]),
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_LOCKDOWN)
(new SystemConfigurationModel('quick_entry'))
->setTranslation('quick_entry.title')
->setTranslationDomain('messages')
->setConfiguration([
(new Configuration())
->setName('quick_entry.recent_activities')
->setType(IntegerType::class)
->setTranslationDomain('system-configuration')
->setRequired(false)
->setConstraints([
new Range(['min' => 0, 'max' => 20]),
]),
]),
(new SystemConfigurationModel('lockdown_period'))
->setConfiguration([
(new Configuration())
->setName('timesheet.rules.lockdown_period_start')
@@ -417,8 +427,7 @@ final class SystemConfigurationController extends AbstractController
->setConstraints([new DateTimeFormat()])
->setTranslationDomain('system-configuration'),
]),
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_ROUNDING)
(new SystemConfigurationModel('rounding'))
->setConfiguration([
(new Configuration())
->setName('timesheet.rounding.default.mode')
@@ -450,8 +459,9 @@ final class SystemConfigurationController extends AbstractController
->setType(WeekDaysType::class)
->setTranslationDomain('system-configuration'),
]),
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_FORM_INVOICE)
(new SystemConfigurationModel('invoice'))
->setTranslation('invoices')
->setTranslationDomain('messages')
->setConfiguration([
// TODO that should be a custom type with validation
(new Configuration())
@@ -468,8 +478,7 @@ final class SystemConfigurationController extends AbstractController
->setTranslationDomain('system-configuration'),
]),
$authentication,
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_FORM_CUSTOMER)
(new SystemConfigurationModel('customer'))
->setConfiguration([
(new Configuration())
->setName('defaults.customer.timezone')
@@ -487,9 +496,26 @@ final class SystemConfigurationController extends AbstractController
->setLabel('currency')
->setType(CurrencyType::class)
->setOptions(['help' => 'default_value_new']),
(new Configuration())
->setName('customer.choice_pattern')
->setLabel('choice_pattern')
->setType(CustomerTypePatternType::class),
]),
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_FORM_USER)
(new SystemConfigurationModel('project'))
->setConfiguration([
(new Configuration())
->setName('project.choice_pattern')
->setLabel('choice_pattern')
->setType(ProjectTypePatternType::class),
]),
(new SystemConfigurationModel('activity'))
->setConfiguration([
(new Configuration())
->setName('activity.choice_pattern')
->setLabel('choice_pattern')
->setType(ActivityTypePatternType::class),
]),
(new SystemConfigurationModel('user'))
->setConfiguration([
(new Configuration())
->setName('defaults.user.timezone')
@@ -518,8 +544,7 @@ final class SystemConfigurationController extends AbstractController
->setType(CheckboxType::class)
->setTranslationDomain('system-configuration'),
]),
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_THEME)
(new SystemConfigurationModel('theme'))
->setConfiguration([
(new Configuration())
->setName('theme.autocomplete_chars')
@@ -557,8 +582,9 @@ final class SystemConfigurationController extends AbstractController
->setType(CheckboxType::class)
->setTranslationDomain('system-configuration'),
]),
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_CALENDAR)
(new SystemConfigurationModel('calendar'))
->setTranslation('calendar')
->setTranslationDomain('messages')
->setConfiguration([
(new Configuration())
->setName('calendar.week_numbers')
@@ -598,9 +624,12 @@ final class SystemConfigurationController extends AbstractController
->setTranslationDomain('system-configuration')
->setType(IntegerType::class)
->setConstraints([new Range(['min' => 0, 'max' => 20]), new NotNull()]),
(new Configuration())
->setName('calendar.title_pattern')
->setTranslationDomain('system-configuration')
->setType(CalendarTitlePatternType::class),
]),
(new SystemConfigurationModel())
->setSection(SystemConfigurationModel::SECTION_BRANDING)
(new SystemConfigurationModel('branding'))
->setConfiguration([
(new Configuration())
->setName('theme.branding.logo')
@@ -632,7 +661,5 @@ final class SystemConfigurationController extends AbstractController
->setOptions(['input' => 'string']),
]),
];
return $configurationModels;
}
}

View File

@@ -22,6 +22,8 @@ use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
/**
* @Route(path="/admin/teams")
@@ -81,11 +83,19 @@ final class TeamController extends AbstractController
}
/**
* @Route(path="/{id}/duplicate", name="team_duplicate", methods={"GET", "POST"})
* @Route(path="/{id}/duplicate/{token}", name="team_duplicate", methods={"GET", "POST"})
* @Security("is_granted('edit', team) and is_granted('create_team')")
*/
public function duplicateTeam(Team $team, Request $request)
public function duplicateTeam(Team $team, string $token, CsrfTokenManagerInterface $csrfTokenManager)
{
if (!$csrfTokenManager->isTokenValid(new CsrfToken('team.duplicate', $token))) {
$this->flashError('action.csrf.error');
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
}
$csrfTokenManager->refreshToken('team.duplicate');
$newTeam = clone $team;
$newTeam->setName($team->getName() . ' [COPY]');

View File

@@ -243,6 +243,7 @@ abstract class TimesheetAbstractController extends AbstractController
protected function export(Request $request, ServiceExport $serviceExport): Response
{
$query = $this->createDefaultQuery();
$query->setOrder(TimesheetQuery::ORDER_ASC);
$form = $this->getExportForm($query);
@@ -455,6 +456,7 @@ abstract class TimesheetAbstractController extends AbstractController
'action' => $this->generateUrl($this->getMultiUpdateRoute(), []),
'method' => 'POST',
'include_exported' => $this->isGranted($this->getPermissionEditExport()),
'include_billable' => $this->isGranted($this->getPermissionEditBillable()),
'include_rate' => $this->isGranted($this->getPermissionEditRate()),
'include_user' => $this->includeUserInForms('multi'),
]);
@@ -482,6 +484,7 @@ abstract class TimesheetAbstractController extends AbstractController
'action' => $action,
'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry),
'include_billable' => $this->isGranted('edit_billable', $entry),
'include_user' => $this->includeUserInForms('create'),
'allow_begin_datetime' => $mode->canEditBegin(),
'allow_end_datetime' => $mode->canEditEnd(),
@@ -510,6 +513,7 @@ abstract class TimesheetAbstractController extends AbstractController
]),
'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry),
'include_billable' => $this->isGranted('edit_billable', $entry),
'include_user' => $this->includeUserInForms('edit'),
'allow_begin_datetime' => $mode->canEditBegin(),
'allow_end_datetime' => $mode->canEditEnd(),
@@ -549,6 +553,11 @@ abstract class TimesheetAbstractController extends AbstractController
return 'edit_export_own_timesheet';
}
protected function getPermissionEditBillable(): string
{
return 'edit_billable_own_timesheet';
}
protected function getPermissionEditRate(): string
{
return 'edit_rate_own_timesheet';

View File

@@ -153,8 +153,9 @@ class TimesheetTeamController extends TimesheetAbstractController
return $this->createForm(TimesheetMultiUserEditForm::class, $entry, [
'action' => $this->generateUrl('admin_timesheet_create_multiuser'),
'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry),
'include_rate' => $this->isGranted($this->getPermissionEditRate()),
'include_exported' => $this->isGranted($this->getPermissionEditExport()),
'include_billable' => $this->isGranted($this->getPermissionEditBillable()),
'include_user' => $this->includeUserInForms('create'),
'allow_begin_datetime' => $mode->canEditBegin(),
'allow_end_datetime' => $mode->canEditEnd(),
@@ -205,6 +206,11 @@ class TimesheetTeamController extends TimesheetAbstractController
return 'edit_export_other_timesheet';
}
protected function getPermissionEditBillable(): string
{
return 'edit_billable_other_timesheet';
}
protected function getPermissionEditRate(): string
{
return 'edit_rate_other_timesheet';

View File

@@ -136,7 +136,9 @@ final class UserController extends AbstractController
$user->setTimezone($firstUser->getTimezone());
$editForm = $this->getCreateUserForm($user);
$editForm->get('create_more')->setData(true);
if ($editForm->has('create_more')) {
$editForm->get('create_more')->setData(true);
}
}
return $this->render('user/create.html.twig', [

View File

@@ -0,0 +1,47 @@
<?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\DataFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
/**
* This fixture makes sure that all data is loaded.
*
* @codeCoverageIgnore
*/
class AllFixtures extends Fixture implements DependentFixtureInterface
{
/**
* @return class-string[]
*/
public function getDependencies()
{
return [
UserFixtures::class,
CustomerFixtures::class,
TagFixtures::class,
TimesheetFixtures::class,
];
}
/**
* {@inheritdoc}
*/
public function load(ObjectManager $manager)
{
// this is a fake fixture class, it only exists to make developers life easier
// if we use the DependentFixtureInterface on the TimesheetFixture directly,
// we cannot load
// bin/console doctrine:fixtures:load --append --group=timesheet
// without executing all dependent fixtures
}
}

View File

@@ -43,11 +43,9 @@ class TagFixtures extends Fixture
$tag = new Tag();
$tagName = null;
if ($i % 2 == 9) {
$tagName = $faker->companyEmail;
} elseif ($i % 2 == 8) {
if ($i % 18 == 0) {
$tagName = $faker->firstName;
} elseif ($i % 2 == 7) {
} elseif ($i % 7 == 0) {
$tagName = $faker->lastName;
} elseif ($i % 5 == 0) {
$tagName = $faker->city;

View File

@@ -13,7 +13,6 @@ use App\Entity\Project;
use App\Entity\Team;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use Faker\Factory;
@@ -26,20 +25,15 @@ use Faker\Factory;
*
* @codeCoverageIgnore
*/
class TeamFixtures extends Fixture implements DependentFixtureInterface
class TeamFixtures extends Fixture
{
public const AMOUNT_TEAMS = 10;
public const MAX_USERS_PER_TEAM = 15;
public const MAX_PROJECTS_PER_TEAM = 5;
/**
* @return string[]
*/
public function getDependencies()
public static function getGroups(): array
{
return [
UserFixtures::class,
];
return ['users', 'team'];
}
/**

View File

@@ -18,7 +18,6 @@ use App\Entity\UserPreference;
use App\Timesheet\Util;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use Faker\Factory;
@@ -26,20 +25,19 @@ use Faker\Factory;
* Defines the sample data to load in the database when running the unit and
* functional tests or while development.
*
* Execute this command to load the data:
* Execute this command to load add fixture data:
* bin/console doctrine:fixtures:load
*
* Or simply append it to your running installation:
* bin/console doctrine:fixtures:load --append --group=timesheet
*
* @codeCoverageIgnore
*/
class TimesheetFixtures extends Fixture implements DependentFixtureInterface, FixtureGroupInterface
class TimesheetFixtures extends Fixture implements FixtureGroupInterface
{
public const MIN_TIMESHEETS_PER_USER = 50;
public const MAX_TIMESHEETS_PER_USER = 500;
public const MAX_TIMESHEETS_TOTAL = 5000;
public const MAX_RUNNING_TIMESHEETS_PER_USER = 1;
public const TIMERANGE_BEGIN_DAYS = 1; // yesterday
public const TIMERANGE_END_DAYS = 1095; // 3 years
public const TIMERANGE_RUNNING = 1047; // in minutes = 17:45 hours
public const MAX_TIMESHEETS_TOTAL = 200;
public const MIN_MINUTES_PER_ENTRY = 15;
public const MAX_MINUTES_PER_ENTRY = 840; // 14h
public const MAX_DESCRIPTION_LENGTH = 200;
@@ -49,23 +47,16 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface, Fi
public const BATCH_SIZE = 100;
/**
* @return class-string[]
*/
public function getDependencies()
{
return [
UserFixtures::class,
CustomerFixtures::class,
TagFixtures::class,
];
}
public static function getGroups(): array
{
return ['timesheet'];
}
public function getRandomFirstDay(): \DateTime
{
return new \DateTime(rand(-1095, 14) . ' days');
}
/**
* {@inheritdoc}
*/
@@ -95,7 +86,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface, Fi
if ($i % 3 === 0) {
$description = $faker->realText($faker->numberBetween(10, self::MAX_DESCRIPTION_LENGTH));
} elseif ($i % 7 === 0) {
$description = substr($faker->text, 0, self::MAX_DESCRIPTION_LENGTH);
$description = substr($faker->text(), 0, self::MAX_DESCRIPTION_LENGTH);
}
$entry = $this->createTimesheetEntry(
@@ -117,8 +108,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface, Fi
}
// create active recordings for test user
$activeEntries = rand(0, self::MAX_RUNNING_TIMESHEETS_PER_USER);
for ($i = 0; $i < $activeEntries; $i++) {
if (rand(0, 10) >= 5) {
$entry = $this->createTimesheetEntry(
$user,
$activities[array_rand($activities)],
@@ -218,8 +208,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface, Fi
private function createTimesheetEntry(User $user, Activity $activity, Project $project, ?string $description, bool $setEndDate)
{
$start = new \DateTime();
$start = $start->modify('- ' . (rand(self::TIMERANGE_BEGIN_DAYS, self::TIMERANGE_END_DAYS)) . ' days');
$start = $this->getRandomFirstDay();
$start = $start->modify('- ' . (rand(1, 86400)) . ' seconds');
$start->setTimezone(new \DateTimeZone($user->getPreferenceValue(UserPreference::TIMEZONE, date_default_timezone_get())));
@@ -246,7 +235,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface, Fi
} else {
// running entries should be short
$newBegin = clone $entry->getBegin();
$newBegin->setTimestamp(time())->modify('- ' . rand(10, self::TIMERANGE_RUNNING) . ' minutes');
$newBegin->setTimestamp(time())->modify('- ' . rand(self::MIN_MINUTES_PER_ENTRY, self::MAX_MINUTES_PER_ENTRY) . ' minutes');
$entry->setBegin($newBegin);
}

View File

@@ -12,6 +12,7 @@ namespace App\DataFixtures;
use App\Entity\User;
use App\Entity\UserPreference;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager;
use Faker\Factory;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
@@ -25,7 +26,7 @@ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
*
* @codeCoverageIgnore
*/
class UserFixtures extends Fixture
class UserFixtures extends Fixture implements FixtureGroupInterface
{
public const DEFAULT_PASSWORD = 'kitten';
public const DEFAULT_API_TOKEN = 'api_kitten';
@@ -63,6 +64,11 @@ class UserFixtures extends Fixture
$this->loadTestUsers($manager);
}
public static function getGroups(): array
{
return ['user', 'users'];
}
/**
* Default users for all test cases
*

View File

@@ -100,6 +100,7 @@ class AppExtension extends Extension
$container->setParameter('kimai.i18n_domains', $localTranslations);
// this should happen always at the end, so bundles do not mess with the base configuration
/* @phpstan-ignore-next-line */
if ($container->hasParameter('kimai.bundles.config')) {
$bundleConfig = $container->getParameter('kimai.bundles.config');
if (!\is_array($bundleConfig)) {

View File

@@ -30,7 +30,7 @@ class Configuration implements ConfigurationInterface
/**
* {@inheritdoc}
*/
public function getConfigTreeBuilder()
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('kimai');
/** @var ArrayNodeDefinition $node */
@@ -69,13 +69,32 @@ class Configuration implements ConfigurationInterface
->append($this->getPermissionsNode())
->append($this->getLdapNode())
->append($this->getSamlNode())
->append($this->getQuickEntryNode())
->end()
->end();
return $treeBuilder;
}
protected function getTimesheetNode()
private function getQuickEntryNode()
{
$builder = new TreeBuilder('quick_entry');
/** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode();
$node
->addDefaultsIfNotSet()
->children()
->integerNode('recent_activities')
->defaultValue(5)
->end()
->end()
;
return $node;
}
private function getTimesheetNode(): ArrayNodeDefinition
{
$builder = new TreeBuilder('timesheet');
/** @var ArrayNodeDefinition $node */
@@ -259,7 +278,7 @@ class Configuration implements ConfigurationInterface
return $node;
}
protected function getInvoiceNode()
private function getInvoiceNode(): ArrayNodeDefinition
{
$builder = new TreeBuilder('invoice');
/** @var ArrayNodeDefinition $node */
@@ -292,7 +311,7 @@ class Configuration implements ConfigurationInterface
return $node;
}
protected function getExportNode()
private function getExportNode()
{
$builder = new TreeBuilder('export');
/** @var ArrayNodeDefinition $node */
@@ -319,7 +338,7 @@ class Configuration implements ConfigurationInterface
return $node;
}
protected function getLanguagesNode()
private function getLanguagesNode(): ArrayNodeDefinition
{
$builder = new TreeBuilder('languages');
/** @var ArrayNodeDefinition $node */
@@ -349,7 +368,7 @@ class Configuration implements ConfigurationInterface
return $node;
}
protected function getCalendarNode()
private function getCalendarNode(): ArrayNodeDefinition
{
$builder = new TreeBuilder('calendar');
/** @var ArrayNodeDefinition $node */
@@ -410,13 +429,17 @@ class Configuration implements ConfigurationInterface
->thenInvalid('The dragdrop_amount must be between 0 and 20')
->end()
->end()
->enumNode('title_pattern')
->values(['{activity}', '{project}', '{customer}', '{description}'])
->defaultValue('{activity}')
->end()
->end()
;
return $node;
}
protected function getThemeNode()
private function getThemeNode(): ArrayNodeDefinition
{
$builder = new TreeBuilder('theme');
/** @var ArrayNodeDefinition $node */
@@ -506,7 +529,7 @@ class Configuration implements ConfigurationInterface
return $node;
}
protected function getIndustryNode()
private function getIndustryNode(): ArrayNodeDefinition
{
$builder = new TreeBuilder('industry');
/** @var ArrayNodeDefinition $node */
@@ -522,7 +545,7 @@ class Configuration implements ConfigurationInterface
return $node;
}
protected function getCompanyNode()
private function getCompanyNode(): ArrayNodeDefinition
{
$builder = new TreeBuilder('company');
/** @var ArrayNodeDefinition $node */
@@ -538,7 +561,7 @@ class Configuration implements ConfigurationInterface
return $node;
}
protected function getUserNode()
private function getUserNode(): ArrayNodeDefinition
{
$builder = new TreeBuilder('user');
/** @var ArrayNodeDefinition $node */
@@ -568,7 +591,7 @@ class Configuration implements ConfigurationInterface
return $node;
}
protected function getWidgetsNode()
private function getWidgetsNode(): ArrayNodeDefinition
{
$builder = new TreeBuilder('widgets');
/** @var ArrayNodeDefinition $node */
@@ -595,7 +618,7 @@ class Configuration implements ConfigurationInterface
return $node;
}
protected function getDashboardNode()
private function getDashboardNode(): ArrayNodeDefinition
{
$builder = new TreeBuilder('dashboard');
/** @var ArrayNodeDefinition $node */
@@ -624,7 +647,7 @@ class Configuration implements ConfigurationInterface
return $node;
}
protected function getDefaultsNode()
private function getDefaultsNode(): ArrayNodeDefinition
{
$builder = new TreeBuilder('defaults');
/** @var ArrayNodeDefinition $node */
@@ -662,7 +685,7 @@ class Configuration implements ConfigurationInterface
return $node;
}
protected function getPermissionsNode()
private function getPermissionsNode(): ArrayNodeDefinition
{
$builder = new TreeBuilder('permissions');
/** @var ArrayNodeDefinition $node */
@@ -712,7 +735,7 @@ class Configuration implements ConfigurationInterface
return $node;
}
protected function getLdapNode()
private function getLdapNode(): ArrayNodeDefinition
{
$treeBuilder = new TreeBuilder('ldap');
$node = $treeBuilder->getRootNode();
@@ -835,7 +858,7 @@ class Configuration implements ConfigurationInterface
return $node;
}
protected function getSamlNode()
private function getSamlNode(): ArrayNodeDefinition
{
$builder = new TreeBuilder('saml');
/** @var ArrayNodeDefinition $node */

View File

@@ -47,7 +47,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* }
* )
*
* @Exporter\Order({"id", "name", "project", "budget", "timeBudget", "budgetType", "color", "visible", "comment"})
* @Exporter\Order({"id", "name", "project", "budget", "timeBudget", "budgetType", "color", "visible", "comment", "billable"})
* @Exporter\Expose("project", label="label.project", exp="object.getProject() === null ? null : object.getProject().getName()")
*/
class Activity implements EntityWithMetaFields, EntityWithBudget
@@ -99,10 +99,10 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
/**
* Description of this activity
*
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Activity_Entity"})
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.comment")
*
@@ -123,6 +123,18 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
* @Assert\NotNull()
*/
private $visible = true;
/**
* @var bool
*
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.billable", type="boolean")
*
* @ORM\Column(name="billable", type="boolean", nullable=false)
* @Assert\NotNull()
*/
private $billable = true;
/**
* Meta fields
*
@@ -227,6 +239,16 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
return $this->visible;
}
public function setBillable(bool $billable): void
{
$this->billable = $billable;
}
public function isBillable(): bool
{
return $this->billable;
}
/**
* @return Collection|MetaTableTypeInterface[]
*/
@@ -261,6 +283,20 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
return null;
}
/**
* @param string $name
* @return bool|int|string|null
*/
public function getMetaFieldValue(string $name)
{
$field = $this->getMetaField($name);
if ($field === null) {
return null;
}
return $field->getValue();
}
public function setMetaField(MetaTableTypeInterface $meta): EntityWithMetaFields
{
if (null === ($current = $this->getMetaField($meta->getName()))) {
@@ -325,7 +361,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
$currentMeta = $this->meta;
$this->meta = new ArrayCollection();
/** @var ProjectMeta $meta */
/** @var ActivityMeta $meta */
foreach ($currentMeta as $meta) {
$newMeta = clone $meta;
$newMeta->setEntity($this);

View File

@@ -51,7 +51,7 @@ trait BudgetTrait
* - null = default / full time
* - month = monthly budget
*
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Activity_Entity", "Project_Entity", "Customer_Entity"})

View File

@@ -20,7 +20,7 @@ trait ColorTrait
/**
* The assigned color in HTML hex format, eg. #dd1d00
*
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
@@ -32,9 +32,6 @@ trait ColorTrait
*/
private $color = null;
/**
* @return string
*/
public function getColor(): ?string
{
if ($this->color === Constants::DEFAULT_COLOR) {
@@ -49,9 +46,6 @@ trait ColorTrait
return null !== $this->color && $this->color !== Constants::DEFAULT_COLOR;
}
/**
* @param string $color
*/
public function setColor(?string $color = null): void
{
$this->color = $color;

View File

@@ -30,10 +30,8 @@ class Configuration
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(name="id", type="integer")
* @phpstan-ignore-next-line
*/
private $id;
/**
* @var string
*
@@ -42,12 +40,10 @@ class Configuration
* @Assert\Length(min=2, max=100, allowEmptyString=false)
*/
private $name;
/**
* @var string
* @var string|null
*
* @ORM\Column(name="value", type="string", length=1024, nullable=true)
* @Assert\Length(max=1024, allowEmptyString=true)
* @ORM\Column(name="value", type="text", nullable=true)
*/
private $value;

View File

@@ -27,7 +27,7 @@ use Symfony\Component\Validator\Constraints as Assert;
*
* @Serializer\ExclusionPolicy("all")
*
* @Exporter\Order({"id", "name", "company", "number", "vatId", "address", "contact","email", "phone", "mobile", "fax", "homepage", "country", "currency", "timezone", "budget", "timeBudget", "budgetType", "color", "visible", "teams", "comment"})
* @Exporter\Order({"id", "name", "company", "number", "vatId", "address", "contact","email", "phone", "mobile", "fax", "homepage", "country", "currency", "timezone", "budget", "timeBudget", "budgetType", "color", "visible", "teams", "comment", "billable"})
* @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array")
*/
class Customer implements EntityWithMetaFields, EntityWithBudget
@@ -64,7 +64,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $name;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
@@ -76,10 +76,10 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $number;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.comment")
*
@@ -99,7 +99,19 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $visible = true;
/**
* @var string
* @var bool
*
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.billable", type="boolean")
*
* @ORM\Column(name="billable", type="boolean", nullable=false)
* @Assert\NotNull()
*/
private $billable = true;
/**
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@@ -111,7 +123,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $company;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@@ -123,7 +135,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $vatId;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@@ -135,7 +147,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $contact;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@@ -174,7 +186,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $currency = self::DEFAULT_CURRENCY;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@@ -186,7 +198,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $phone;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@@ -198,7 +210,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $fax;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@@ -214,7 +226,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*
* Limited via RFC to 254 chars
*
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@@ -226,7 +238,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*/
private $email;
/**
* @var string
* @var string|null
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
@@ -353,6 +365,16 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
return $this->visible;
}
public function setBillable(bool $billable): void
{
$this->billable = $billable;
}
public function isBillable(): bool
{
return $this->billable;
}
public function setCompany(?string $company): Customer
{
$this->company = $company;
@@ -531,6 +553,20 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
return null;
}
/**
* @param string $name
* @return bool|int|string|null
*/
public function getMetaFieldValue(string $name)
{
$field = $this->getMetaField($name);
if ($field === null) {
return null;
}
return $field->getValue();
}
public function setMetaField(MetaTableTypeInterface $meta): EntityWithMetaFields
{
if (null === ($current = $this->getMetaField($meta->getName()))) {
@@ -595,7 +631,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
$currentMeta = $this->meta;
$this->meta = new ArrayCollection();
/** @var ProjectMeta $meta */
/** @var CustomerMeta $meta */
foreach ($currentMeta as $meta) {
$newMeta = clone $meta;
$newMeta->setEntity($this);

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