Compare commits

...

8 Commits

Author SHA1 Message Date
Kevin Papst
b1cc5f2587 fix test 2023-10-31 16:58:47 +01:00
Kevin Papst
6b672a23b4 new "customer overview" listing report 2023-10-31 16:32:11 +01:00
Kevin Papst
900254b83a change default visibility 2023-10-31 16:30:01 +01:00
Kevin Papst
114617a052 clarify EOL of Kimai 1 2023-10-20 16:30:20 +02:00
Kevin Papst
38e37f1c2e Release 2.1.0 (#4321)
* fix deprecations
* remove unused config
* replace invalid annotation type with attribute
* use AsDoctrineListener to fix deprecation
* new ModifiedSubscriber to support custom logic and fix deprecation
* removed inheritdoc comment
* new ModifiedSubscriber to support custom logic and fix deprecation
* cleanup event dispatcher interface
* re-order annotation params
* one more doctrine based deprecation
* fix query to count active timesheets
* link to "all times" to identify active timesheets
* link icon instead of text
* fix "skin" translation in wizard
* use duration filter to show duration
* added login link command and controller
* bump tabler theme to 1.0
* added wizard to force password reset by user
* allow to configure that new accounts need to reset their password
* prevent uploading twig templates by default
* bump composer packages
* enable sandbox and basic security measures for custom twig templates for invoice and export
* bump to symfony 6.3.5
* allow to export single user reports to excel
* removed broken method to reload twig cache
* added api parameter to fetch user collection fully serialized
* allow to replace or append description via timesheet batch update
* show api username above form
2023-10-19 11:21:50 +02:00
Kevin Papst
7a5b12762a Release 2.0.35 (#4302) 2023-09-23 18:15:22 +02:00
Kevin Papst
de419350b2 Release 2.0.34 (#4281) 2023-09-17 22:32:01 +02:00
Weblate (bot)
a6b237936e Translated using Weblate (#4282)
Co-authored-by: Kieran W <alarmcuddly@outlook.com>
Co-authored-by: Massimo Pissarello <mapi68@gmail.com>
Co-authored-by: Mykola Skira <mykola.skira@icloud.com>
Co-authored-by: No Pasaran <damiengourdin@gmail.com>
Co-authored-by: Wellington Terumi Uemura <wellingtonuemura@gmail.com>
Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Co-authored-by: yangyangdaji <1504305527@qq.com>
Co-authored-by: Łukasz Kosiński <lukikosin@gmail.com>
2023-09-11 23:18:07 +02:00
306 changed files with 4132 additions and 3412 deletions

View File

@@ -16,10 +16,10 @@
# For MySQL that would be "serverVersion=5.7" as in:
# DATABASE_URL=mysql://user:password@127.0.0.1:3306/database?charset=utf8mb4&serverVersion=5.7
#
# For MariaDB it would be "serverVersion=mariadb-10.5.8":
# DATABASE_URL=mysql://user:password@127.0.0.1:3306/database?charset=utf8mb4&serverVersion=mariadb-10.5.8
# For MariaDB it would be "serverVersion=10.5.8-MariaDB":
# DATABASE_URL=mysql://user:password@127.0.0.1:3306/database?charset=utf8mb4&serverVersion=10.5.8-MariaDB
#
DATABASE_URL=mysql://user:password@127.0.0.1:3306/database?charset=utf8mb4&serverVersion=5.7
DATABASE_URL=mysql://user:password@127.0.0.1:3306/database?charset=utf8mb4&serverVersion=10.5.8-MariaDB
#================================================================================
# The full documentation can be found at https://www.kimai.org/documentation/emails.html

37
.gitignore vendored
View File

@@ -1,45 +1,34 @@
# some hosters require a htaccess to change the PHP version
.htaccess
.env-*
.idea/
.DS_Store
rector.php
phpstan.sh
/public/.htaccess
/.env-*
/.idea/
/.DS_Store
/phpstan.sh
# custom apache rules e.g. to deactivate ioncube loader
public/.user.ini
/public/.user.ini
# for symfony local webserver
php.ini
.php-version
/php.ini
/.php-version
# YARN 2
.yarnrc.yml
.yarn
/.yarnrc.yml
/.yarn
# for keeping empty directories
!.gitkeep
/bin/*
!/bin/console
*local.yaml
/config/packages/local.yaml
/config/bundles-local.php
/templates/invoice/renderer/.~lock*
/translations/branding.*.xlf
/var/data/*
/var/coverage/
/var/cache/*
/var/invoices/*
/var/invoices*
/var/export/*
/var/log/*
/var/sessions/*
/var/packages/*
/var/plugins/*
/var/plugins_old/
*.disabled
/var/plugins/*/*.disabled
###> symfony/framework-bundle ###
/.env.local

View File

@@ -33,7 +33,7 @@ $fixer
'no_break_comment' => true,
'no_closing_tag' => true,
'no_spaces_after_function_name' => true,
'no_spaces_inside_parenthesis' => true,
'spaces_inside_parentheses' => ['space' => 'none'],
'no_trailing_whitespace' => true,
'no_trailing_whitespace_in_comment' => true,
'single_blank_line_at_eof' => true,
@@ -54,7 +54,7 @@ $fixer
'class_attributes_separation' => ['elements' => ['method' => 'one']],
'concat_space' => ['spacing' => 'one'],
'declare_equal_normalize' => true,
'function_typehint_space' => true,
'type_declaration_spaces' => ['elements' => ['function', 'property']],
'include' => true,
'lowercase_cast' => true,
'lowercase_static_reference' => true,
@@ -123,7 +123,7 @@ $fixer
'return_type_declaration' => true,
'semicolon_after_instruction' => true,
'short_scalar_cast' => true,
'single_blank_line_before_namespace' => true,
'blank_lines_before_namespace' => ['min_line_breaks' => 2, 'max_line_breaks' => 2],
'single_line_comment_style' => [
'comment_types' => ['hash'],
],

View File

@@ -23,12 +23,12 @@ and so much more.
### Versions
There are two versions of Kimai existing:
There are two [versions](https://www.kimai.org/documentation/versions.html) of Kimai existing:
- [Version 2](https://github.com/kimai/kimai) — the current stable release (PHP 8.1 only)
- [Version 1](https://github.com/kimai/kimai/tree/1.x) — the "old" version, in maintenance mode since Jan. 2023 (PHP 7.4, PHP 8.1)
- [Version 2](https://github.com/kimai/kimai) — the current stable release (PHP 8.1+)
- [Version 1](https://github.com/kimai/kimai/tree/1.x) — EOL since mid of 2023 (PHP 7.4)
If you start fresh, do **not** use Version 1, it won't receive any more updates.
Do **NOT** use Version 1, it won't get any more updates!
### Links
@@ -74,8 +74,6 @@ It is open for changes and input from the community, your [ideas and questions](
Release versions will be created on a regular basis, every couple of weeks latest.
Every code change, whether it's a new feature or a bugfix, will be done on the `main` branch.
Until 2.0 is widely adopted, the [1.x branch](https://github.com/kimai/kimai/tree/1.x) will receive (critical) bug and security fixes.
## Contributing
You want to contribute to this repository? This is so great!

View File

@@ -11,7 +11,7 @@ Perform EACH version specific task between your version and the new one, otherwi
## [2.0.30](https://github.com/kimai/kimai/releases/tag/2.0.30)
The `DATABASE_URL` in your environment settings ([.env](https://github.com/kimai/kimai/issues/4246) or in your [Docker](https://github.com/tobybatch/kimai2/issues/531) or webserver config)
now requires the `charset` and `serverVersion` params, e.g.: `DATABASE_URL=mysql://user:password@127.0.0.1:3306/database?charset=utf8mb4&serverVersion=5.7` (examples in `.env`).
now requires the `charset` and `serverVersion` params, e.g.: `DATABASE_URL=mysql://user:password@127.0.0.1:3306/database?charset=utf8mb4&serverVersion=10.5.8-MariaDB` (examples in `.env`).
## [2.0](https://github.com/kimai/kimai/releases/tag/2.0)
@@ -21,9 +21,9 @@ now requires the `charset` and `serverVersion` params, e.g.: `DATABASE_URL=mysql
- All plugins need to be updated: delete all previous version from your installation (`rm -r var/plugins/*`) before updating!
- The `local.yaml` is not compatible with old version, remove it before the update and then re-create it after everything works
- dashboard default config
- removed: theme.branding.translation
- removed: kimai.plugin_dir
- removed: configuring the `dashboard` is not supported any longer
- removed: custom translation files via `theme.branding.translation`
- removed: changing the plugin directory via `kimai.plugin_dir`
### Developer

View File

@@ -440,8 +440,14 @@ export default class KimaiCalendar {
calendarSource = {...calendarSource, ...{
id: 'kimai-' + source.id,
events: (fetchInfo, successCallback, failureCallback) => {
let url = source.url.replace('{from}', DATES.formatForAPI(fetchInfo.start));
url = url.replace('{to}', DATES.formatForAPI(fetchInfo.end));
const targetFrom = DATES.formatForAPI(fetchInfo.start);
const targetTo = DATES.formatForAPI(fetchInfo.end);
let url = source.url;
url = url.replace('{from}', targetFrom);
url = url.replace('__FROM__', targetFrom);
url = url.replace('{to}', targetTo);
url = url.replace('__TO__', targetTo);
API.get(url, {}, result => {
let apiEvents = [];
@@ -463,8 +469,14 @@ export default class KimaiCalendar {
id: 'json-' + source.id,
editable: false,
events: (fetchInfo, successCallback, failureCallback) => {
let url = source.url.replace('{from}', DATES.formatForAPI(fetchInfo.start));
url = url.replace('{to}', DATES.formatForAPI(fetchInfo.end));
const targetFrom = DATES.formatForAPI(fetchInfo.start);
const targetTo = DATES.formatForAPI(fetchInfo.end);
let url = source.url;
url = url.replace('{from}', targetFrom);
url = url.replace('__FROM__', targetFrom);
url = url.replace('{to}', targetTo);
url = url.replace('__TO__', targetTo);
API.get(url, {}, result => {
let apiEvents = [];

View File

@@ -37,10 +37,6 @@
border-radius: var(--tblr-border-radius);
}
fieldset:empty {
display: none;
}
/* e.g. the work contract settings is user profile render rows with mb-3 and the last row doesn't need that */
fieldset > .mb-3.row:last-child {
margin-bottom: 0!important;

View File

@@ -15,4 +15,20 @@ fieldset.form-fieldset legend {
}
.dropdown-divider {
--tblr-dropdown-divider-margin-y: .5rem;
}
}
/*
fixes translucent scroll border, e.g. in timesheet modal duration dropdown
https://github.com/tabler/tabler/issues/1606
*/
.dropdown-menu {
background-clip: border-box;
}
/*
hide empty fieldset
https://github.com/tabler/tabler/issues/1650
*/
fieldset:empty {
display: none;
}

View File

@@ -30,12 +30,12 @@
"doctrine/doctrine-bundle": "^2.7",
"doctrine/doctrine-migrations-bundle": "^3.0",
"doctrine/orm": "^2.8",
"endroid/qr-code": "^4.4.9",
"endroid/qr-code": "^4.8",
"erusev/parsedown": "^1.6",
"friendsofsymfony/rest-bundle": "^3.0",
"gedmo/doctrine-extensions": "^3.6",
"jms/serializer-bundle": "^5.0",
"kevinpapst/tabler-bundle": "^0.22",
"kevinpapst/tabler-bundle": "^1.0",
"league/csv": "^9.4",
"mpdf/mpdf": "^8.0",
"nelmio/api-doc-bundle": "^4.0",
@@ -44,8 +44,8 @@
"pagerfanta/pagerfanta": "^3.0",
"phpoffice/phpspreadsheet": "^1.16",
"phpoffice/phpword": "^1.0",
"psr/container": "^1.0||^2.0",
"psr/log": "^2.0||^3.0",
"psr/container": "^2.0",
"psr/log": "^3.0",
"scheb/2fa-backup-code": "^6.2",
"scheb/2fa-bundle": "^6.2",
"scheb/2fa-totp": "^6.2",
@@ -68,7 +68,7 @@
"symfony/translation": "^6.0",
"symfony/twig-bundle": "^6.0",
"symfony/validator": "^6.0",
"symfony/webpack-encore-bundle": "^1.16",
"symfony/webpack-encore-bundle": "^2.0",
"symfony/yaml": "^6.0",
"twig/cssinliner-extra": "^3.0",
"twig/extra-bundle": "^3.0",
@@ -129,6 +129,7 @@
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-mbstring": "*",
"symfony/polyfill-intl": "*",
"symfony/polyfill-iconv": "*",
@@ -179,7 +180,13 @@
"tests-unit": "vendor/bin/phpunit --exclude-group integration tests/",
"tests-integration": "vendor/bin/phpunit --group integration tests/",
"phpstan": [
"vendor/bin/phpstan analyse -c phpstan.neon",
"@phpstan-src",
"@phpstan-tests"
],
"phpstan-src": [
"vendor/bin/phpstan analyse -c phpstan.neon"
],
"phpstan-tests": [
"vendor/bin/phpstan analyse -c tests/phpstan.neon"
],
"codestyle": "vendor/bin/php-cs-fixer fix --dry-run --verbose --show-progress=none",
@@ -195,7 +202,7 @@
"symfony": {
"id": "01C3FWRDJJEX9K6Y3A4XDFXPBR",
"allow-contrib": true,
"require": "6.2.*"
"require": "6.3.*"
}
}
}

1943
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ doctrine:
default_table_options:
charset: utf8mb4
collate: utf8mb4_unicode_ci
schema_manager_factory: doctrine.dbal.default_schema_manager_factory
types:
datetime: App\Doctrine\UTCDateTimeType
@@ -26,6 +27,7 @@ doctrine:
default_entity_manager: default
entity_managers:
default:
report_fields_where_declared: true
connection: default
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true

View File

@@ -17,8 +17,6 @@ framework:
cookie_httponly: true
storage_factory_id: session.storage.factory.native
#esi: ~
#fragments: ~
php_errors:
log: true

View File

@@ -151,11 +151,13 @@ kimai:
# --------------------------------------------------------------------------------
# INVOICES
# --------------------------------------------------------------------------------
# invoice:
# # all files in these directories will be used as invoice documents (if supported by a renderer)
# documents:
# - 'var/invoices/'
# - 'templates/invoice/renderer/'
# invoice:
# # all files in these directories will be used as invoice documents (if supported by a renderer)
# documents:
# - 'var/invoices/'
# - 'templates/invoice/renderer/'
# # allow to upload twig templates as invoice documents
# upload_twig: true
# --------------------------------------------------------------------------------

View File

@@ -65,6 +65,12 @@ security:
max_attempts: 5
interval: '5 minutes'
login_link:
check_route: link_login_check
signature_properties: ['id']
lifetime: 300
max_uses: 1
access_decision_manager:
strategy: unanimous
allow_if_all_abstain: false

View File

@@ -1,6 +1,6 @@
controllers:
resource: ../src/Controller/
type: annotation
type: attribute
prefix: /{_locale}
requirements:
_locale: '%app_locales%'
@@ -19,17 +19,17 @@ api.swagger:
api:
resource: ../src/API/
type: annotation
type: attribute
prefix: /api
auth:
resource: ../src/Controller/Auth/
type: annotation
type: attribute
prefix: /auth
security:
resource: ../src/Controller/Security/
type: annotation
type: attribute
prefix: /{_locale}
requirements:
_locale: '%app_locales%'
@@ -38,7 +38,7 @@ security:
kernel:
resource: ../src/Kernel.php
type: annotation
type: attribute
home:
path: /

View File

@@ -91,16 +91,10 @@ services:
App\Doctrine\TimesheetSubscriber:
class: App\Doctrine\TimesheetSubscriber
arguments: [!tagged timesheet.calculator]
tags:
- { name: doctrine.event_subscriber, priority: 50 }
# updates timestampable columns (higher priority, so the TimesheetSubscriber will be executed later)
Gedmo\Timestampable\TimestampableListener:
class: Gedmo\Timestampable\TimestampableListener
tags:
- { name: doctrine.event_subscriber, priority: 60 }
calls:
- [ setAnnotationReader, [ "@annotation_reader" ] ]
App\Doctrine\ModifiedSubscriber:
class: App\Doctrine\ModifiedSubscriber
# ================================================================================
# TIMESHEET RECORD CALCULATOR

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@
<env name="APP_ENV" value="test" force="true"/>
<env name="APP_DEBUG" value="0" force="true"/>
<env name="APP_SECRET" value="5a79a1c866efef9ca1800f971d689f3e" force="true"/>
<env name="DATABASE_URL" value="mysql://kimai2_test:kimai2_test@127.0.0.1:3306/kimai2_test?serverVersion=mariadb-10.7.3"/>
<env name="DATABASE_URL" value="mysql://kimai2_test:kimai2_test@127.0.0.1:3306/kimai2_test?serverVersion=10.5.8-MariaDB"/>
<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"/>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,7 +6,7 @@
"/build/app.9acfcfc8.js"
],
"css": [
"/build/app.405a508c.css"
"/build/app.fc41168c.css"
]
},
"export-pdf": {
@@ -45,7 +45,7 @@
"calendar": {
"js": [
"/build/runtime.f0079159.js",
"/build/calendar.ea514f59.js"
"/build/calendar.3f4f78fe.js"
],
"css": [
"/build/calendar.bb473428.css"
@@ -64,7 +64,7 @@
"integrity": {
"/build/runtime.f0079159.js": "sha384-H22sAW1aTvyIPqvHOvGXWSWTxf0y6mptp+MsVmyXCfjx/WJjBbhX9gbUZ+qIuihV",
"/build/app.9acfcfc8.js": "sha384-tzbF0uEmHghIo+D5IuKarLYkwB3AyrLMTEFqR5mqaXCP5VSOWx3t+ZfD81kd80T+",
"/build/app.405a508c.css": "sha384-D8LqbrX2x0UHuxQasN8nli7f/tzVYlNeJleATqXW1o0Pjwycyo0t1jKRe1h1MeIV",
"/build/app.fc41168c.css": "sha384-3Td9W7KogJoBiywzWOgpkPPNHEIT8/a/SkMZM5NgIRmuChj/3PeOKkOHVJQlhP2E",
"/build/export-pdf.d367a32e.js": "sha384-Z5baqnzjI636nYFs4g63ViIKBZKRW4Jhv/7PQmTEQlqhfA7eK0vUMUtiyy0R5A9u",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.2604495e.js": "sha384-D6JvhGSqlx7z72b/qD3nF3QDXPy+XsCSRGtWfs1icjDKOcd2UzuXwuSa/E1Fg2TJ",
@@ -72,7 +72,7 @@
"/build/invoice-pdf.ce9aace0.js": "sha384-mtZkXrpsXFMX2YvmmTlCm91QgsTlZLzLJnDAFcTuBDtDZ7TNkqLYkfCeLzFLVfwb",
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
"/build/chart.f5becfac.js": "sha384-GSqETm8wULiVXyizvwRompfwu63r/C0Qd/AvrHDE4cqAKiIGCssb3QyBtGu1WN+W",
"/build/calendar.ea514f59.js": "sha384-s162N0JRCI0twVtDROON8kABO+riWgiXM7O6TA175IOkAh94twcLqKoAfycd2BrA",
"/build/calendar.3f4f78fe.js": "sha384-RD8enOf0Foe7IhSOGnv2DCes4QUCvLjlONH0HFq3RK9l/ozrFZakTX6SWwKOoEda",
"/build/calendar.bb473428.css": "sha384-900W9o8666Qpw21rLP7hn5Ql8tWd40k0IcE3QpFm7E7V6bKva7xQgGlyzVuuh4GK",
"/build/dashboard.6774a712.js": "sha384-lBwkNqUPv+IBbznagiFakY2BOIueq/Bg89wHO2XeM9YAZ51rPkrvdMd+XmFupHsM",
"/build/dashboard.18f5a8b7.css": "sha384-PBD8ftb2yBoSjRe2sN5Xb4dEz045kOEUfcIufdSis+tWoWdAx5j4Yic+F/6CYbrP"

View File

@@ -1,5 +1,5 @@
{
"build/app.css": "/build/app.405a508c.css",
"build/app.css": "/build/app.fc41168c.css",
"build/app.js": "/build/app.9acfcfc8.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.d367a32e.js",
@@ -9,7 +9,7 @@
"build/invoice-pdf.js": "/build/invoice-pdf.ce9aace0.js",
"build/chart.js": "/build/chart.f5becfac.js",
"build/calendar.css": "/build/calendar.bb473428.css",
"build/calendar.js": "/build/calendar.ea514f59.js",
"build/calendar.js": "/build/calendar.3f4f78fe.js",
"build/dashboard.css": "/build/dashboard.18f5a8b7.css",
"build/dashboard.js": "/build/dashboard.6774a712.js",
"build/runtime.js": "/build/runtime.f0079159.js"

View File

@@ -21,10 +21,10 @@ use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[Route(path: '/actions')]

View File

@@ -26,8 +26,8 @@ use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

View File

@@ -10,12 +10,12 @@
namespace App\API;
use App\Entity\User;
use App\Repository\Query\BaseQuery;
use App\Timesheet\DateTimeFactory;
use App\Utils\Pagination;
use FOS\RestBundle\View\View;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormTypeInterface;
@@ -30,13 +30,13 @@ abstract class BaseApiController extends AbstractController
/**
* @template TFormType of FormTypeInterface<TData>
* @template TData of mixed
* @template TData of BaseQuery
* @param class-string<TFormType> $type
* @param TData|null $data
* @param TData $data
* @param array<mixed> $options
* @return FormInterface<TData|null>
* @return FormInterface<BaseQuery>
*/
protected function createSearchForm(string $type = FormType::class, mixed $data = null, array $options = []): FormInterface
protected function createSearchForm(string $type, BaseQuery $data, array $options = []): FormInterface
{
return $this->container
->get('form.factory')

View File

@@ -26,8 +26,8 @@ use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

View File

@@ -27,8 +27,8 @@ use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

View File

@@ -33,6 +33,7 @@ use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
@@ -40,7 +41,6 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
#[Route(path: '/timesheets')]
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
@@ -93,7 +93,7 @@ final class TimesheetController extends BaseApiController
#[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', strict: true, nullable: true, description: 'Allows to fetch fully serialized objects including subresources. Allowed values: true (default: false)')]
#[Rest\QueryParam(name: 'full', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Allows to fetch full objects including subresources. Allowed values: 0|1|false|true (default: false)')]
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
#[Rest\QueryParam(name: 'modified_after', requirements: [new 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.')]
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository, ProjectRepository $projectRepository, ActivityRepository $activityRepository, UserRepository $userRepository): Response
@@ -257,7 +257,8 @@ final class TimesheetController extends BaseApiController
$view = new View($results, 200);
$this->addPagination($view, $data);
if (null !== $paramFetcher->get('full')) {
$full = $paramFetcher->get('full');
if ($full === '1' || $full === 'true') {
$view->getContext()->setGroups(self::GROUPS_COLLECTION_FULL);
} else {
$view->getContext()->setGroups(self::GROUPS_COLLECTION);

View File

@@ -23,7 +23,7 @@ use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Security as ApiSecurity;
use OpenApi\Attributes as OA;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -39,6 +39,7 @@ final class UserController extends BaseApiController
public const GROUPS_ENTITY = ['Default', 'Entity', 'User', 'User_Entity'];
public const GROUPS_FORM = ['Default', 'Entity', 'User', 'User_Entity'];
public const GROUPS_COLLECTION = ['Default', 'Collection', 'User'];
public const GROUPS_COLLECTION_FULL = ['Default', 'Collection', 'User', 'User_Entity'];
public function __construct(
private ViewHandlerInterface $viewHandler,
@@ -60,6 +61,7 @@ final class UserController extends BaseApiController
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|username|alias|email', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, username, alias, email (default: username)')]
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
#[Rest\QueryParam(name: 'full', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Allows to fetch full objects including subresources. Allowed values: 0|1|false|true (default: false)')]
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
{
$query = new UserQuery();
@@ -88,7 +90,13 @@ final class UserController extends BaseApiController
$query->setIsApiCall(true);
$data = $this->repository->getUsersForQuery($query);
$view = new View($data, 200);
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
$full = $paramFetcher->get('full');
if ($full === '1' || $full === 'true') {
$view->getContext()->setGroups(self::GROUPS_COLLECTION_FULL);
} else {
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
}
return $this->viewHandler->handle($view);
}

View File

@@ -20,7 +20,7 @@ use App\Event\ActivityUpdatePreEvent;
use App\Repository\ActivityRepository;
use App\Validator\ValidationFailedException;
use InvalidArgumentException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**

View File

@@ -18,7 +18,7 @@ use App\Event\CalendarSourceEvent;
use App\Event\RecentActivityEvent;
use App\Repository\TimesheetRepository;
use App\Utils\Color;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
final class CalendarService
{

View File

@@ -148,7 +148,7 @@ abstract class AbstractBundleInstallerCommand extends Command
return Command::SUCCESS;
}
protected function installAssets(SymfonyStyle $io, OutputInterface $output)
protected function installAssets(SymfonyStyle $io, OutputInterface $output): void
{
$command = $this->getApplication()->find('assets:install');
$cmdInput = new ArrayInput([]);
@@ -160,12 +160,12 @@ abstract class AbstractBundleInstallerCommand extends Command
$io->writeln('');
}
protected function importMigrations(SymfonyStyle $io, OutputInterface $output)
protected function importMigrations(SymfonyStyle $io, OutputInterface $output): void
{
$config = $this->getMigrationConfigFilename();
if (null === $config) {
return false;
return;
}
if (!file_exists($config)) {

View File

@@ -56,5 +56,5 @@ abstract class AbstractRoleCommand extends Command
return Command::SUCCESS;
}
abstract protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role);
abstract protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role): void;
}

View File

@@ -33,10 +33,7 @@ final class DemoteUserCommand extends AbstractRoleCommand
);
}
/**
* {@inheritdoc}
*/
protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role)
protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role): void
{
$username = $user->getUserIdentifier();
if ($super) {

View File

@@ -46,10 +46,7 @@ final class ExportCreateCommand extends Command
parent::__construct();
}
/**
* {@inheritdoc}
*/
protected function configure()
protected function configure(): void
{
$this
->setDescription('Create exports')

View File

@@ -22,6 +22,7 @@ use App\Repository\Query\TimesheetQuery;
use App\Repository\UserRepository;
use App\Timesheet\DateTimeFactory;
use App\Utils\SearchTerm;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
@@ -32,7 +33,6 @@ use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
#[AsCommand(name: 'kimai:invoice:create')]
final class InvoiceCreateCommand extends Command

View File

@@ -33,7 +33,7 @@ final class PromoteUserCommand extends AbstractRoleCommand
);
}
protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role)
protected function executeRoleCommand(UserService $manipulator, SymfonyStyle $output, User $user, bool $super, $role): void
{
$username = $user->getUserIdentifier();
if ($super) {

View File

@@ -106,7 +106,7 @@ final class ReloadCommand extends Command
]
);
return (int) $cacheResult;
return $cacheResult;
}
$io->success(
@@ -116,7 +116,7 @@ final class ReloadCommand extends Command
return Command::SUCCESS;
}
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output)
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
{
$io->text('Rebuilding your cache, please be patient ...');

View File

@@ -0,0 +1,89 @@
<?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\Repository\UserRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
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\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Http\LoginLink\LoginLinkHandlerInterface;
#[AsCommand(name: 'kimai:user:login-link', description: 'Create a URL that can be used to login as that user', hidden: true)]
/**
* @CloudRequired
*/
final class UserLoginLinkCommand extends Command
{
public function __construct(
private LoginLinkHandlerInterface $loginLink,
private UserRepository $userRepository,
private RequestStack $requestStack
)
{
parent::__construct();
$this->addArgument('email', InputArgument::REQUIRED, 'The email of the user');
$this->addOption('password-reset', null, InputOption::VALUE_NONE, 'Whether the user needs to reset the password afterwards');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$email = $input->getArgument('email');
if ($email === null || $email === '') {
$io->error('Need email to create login URL');
return Command::FAILURE;
}
$user = $this->userRepository->findOneBy(['email' => $email]);
if ($user === null) {
$io->error('Need username to create login URL');
return Command::FAILURE;
}
if (!$user->isEnabled()) {
$io->error('User is not enabled');
return Command::FAILURE;
}
if (!$user->isInternalUser()) {
$io->error('User does not use internal login');
return Command::FAILURE;
}
$request = new Request();
$request->setLocale($user->getLocale());
$this->requestStack->push($request);
$loginLinkDetails = $this->loginLink->createLoginLink($user, $request);
$loginLink = $loginLinkDetails->getUrl();
if ($input->getOption('password-reset') === true) {
$user->setPasswordRequestedAt(new \DateTime());
$user->setRequiresPasswordReset(true);
$this->userRepository->saveUser($user);
}
$output->writeln($loginLink);
return Command::SUCCESS;
}
}

View File

@@ -32,14 +32,11 @@ final class SystemConfiguration
/**
* 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.
*
* @param string $key
* @param mixed $value
* @return void
* @internal
*/
private function set(string $key, $value): void
public function set(string $key, mixed $value): void
{
if (\array_key_exists($key, $this->settings)) {
if (\is_bool($this->settings[$key])) {
@@ -125,31 +122,35 @@ final class SystemConfiguration
// ========== Array access methods ==========
/**
* @deprecated since 2.0.35
*/
public function offsetExists($offset): bool
{
return $this->has($offset);
}
/**
* @deprecated since 2.0.35
*/
public function offsetGet($offset): mixed
{
return $this->find($offset);
}
/**
* @param mixed $offset
* @param mixed $value
* @throws \BadMethodCallException
* @deprecated since 2.0.35
*/
public function offsetSet($offset, $value)
public function offsetSet(mixed $offset, mixed $value): void
{
$this->set($offset, $value);
}
/**
* @param mixed $offset
* @deprecated since 2.0.35
* @throws \BadMethodCallException
*/
public function offsetUnset($offset)
public function offsetUnset(mixed $offset): void
{
throw new \BadMethodCallException('SystemBundleConfiguration does not support offsetUnset()');
}

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.0.33';
public const VERSION = '2.1.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 20033;
public const VERSION_ID = 20100;
/**
* The software name
*/
@@ -31,7 +31,7 @@ class Constants
*/
public const GITHUB = 'https://github.com/kimai/kimai/';
/**
* The Github repository name
* The GitHub repository name
*/
public const GITHUB_REPO = 'kimai/kimai';
/**

View File

@@ -17,7 +17,6 @@ use App\Timesheet\DateTimeFactory;
use App\Validator\ValidationFailedException;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as BaseAbstractController;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormTypeInterface;
@@ -76,13 +75,13 @@ abstract class AbstractController extends BaseAbstractController implements Serv
/**
* @template TFormType of FormTypeInterface<TData>
* @template TData of mixed
* @template TData of array|object
* @param class-string<TFormType> $type
* @param TData|null $data
* @param TData $data
* @param array<mixed> $options
* @return FormInterface<TData|null>
* @return FormInterface<TData>
*/
protected function createFormWithName(string $name, string $type = FormType::class, mixed $data = null, array $options = []): FormInterface
protected function createFormWithName(string $name, string $type, mixed $data, array $options = []): FormInterface
{
return $this->container->get('form.factory')->createNamed($name, $type, $data, $options);
}

View File

@@ -35,7 +35,7 @@ use App\Repository\TeamRepository;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Exception;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;

View File

@@ -25,7 +25,7 @@ final class SamlController extends AbstractController
}
#[Route(path: '/login', name: 'saml_login')]
public function loginAction(Request $request)
public function loginAction(Request $request): Response
{
if (!$this->samlConfiguration->isActivated()) {
throw $this->createNotFoundException('SAML deactivated');
@@ -50,11 +50,19 @@ final class SamlController extends AbstractController
throw new \RuntimeException($error);
}
$this->authFactory->create()->login($session->get('_security.main.target_path'));
// this does set headers and exit as $stay is not set to true
$url = $this->authFactory->create()->login($session->get('_security.main.target_path'));
if ($url === null) {
throw new \RuntimeException('SAML login failed');
}
// this line is not (yet) reached, as the previous call will exit
return $this->redirect($url);
}
#[Route(path: '/metadata', name: 'saml_metadata')]
public function metadataAction()
public function metadataAction(): Response
{
if (!$this->samlConfiguration->isActivated()) {
throw $this->createNotFoundException('SAML deactivated');
@@ -69,7 +77,7 @@ final class SamlController extends AbstractController
}
#[Route(path: '/acs', name: 'saml_acs')]
public function assertionConsumerServiceAction()
public function assertionConsumerServiceAction(): Response
{
if (!$this->samlConfiguration->isActivated()) {
throw $this->createNotFoundException('SAML deactivated');
@@ -79,7 +87,7 @@ final class SamlController extends AbstractController
}
#[Route(path: '/logout', name: 'saml_logout')]
public function logoutAction()
public function logoutAction(): Response
{
if (!$this->samlConfiguration->isActivated()) {
throw $this->createNotFoundException('SAML deactivated');

View File

@@ -16,10 +16,10 @@ use App\Reporting\YearByUser\YearByUser;
use App\Utils\PageSetup;
use App\WorkingTime\Model\BoxConfiguration;
use App\WorkingTime\WorkingTimeService;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Users can control their working time statistics
@@ -68,6 +68,7 @@ final class ContractController extends AbstractController
$page = new PageSetup('work_times');
$page->setHelp('contract.html');
$page->setActionName('contract');
$page->setActionPayload(['profile' => $profile, 'year' => $yearDate]);
$page->setPaginationForm($form);
// additional boxes by plugins

View File

@@ -36,7 +36,7 @@ use App\Repository\Query\ProjectQuery;
use App\Repository\TeamRepository;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;

View File

@@ -16,7 +16,7 @@ use App\Repository\BookmarkRepository;
use App\Utils\PageSetup;
use App\Widget\WidgetInterface;
use App\Widget\WidgetService;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;

View File

@@ -13,7 +13,7 @@ use App\Configuration\LocaleService;
use App\Entity\User;
use App\Event\ConfigureMainMenuEvent;
use App\Repository\UserRepository;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

View File

@@ -38,6 +38,7 @@ use App\Repository\Query\InvoiceQuery;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Exception;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface;
@@ -48,7 +49,6 @@ use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Twig\Environment;
/**
@@ -387,7 +387,7 @@ final class InvoiceController extends AbstractController
#[Route(path: '/export', name: 'invoice_export', methods: ['GET'])]
#[IsGranted('view_invoice')]
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter)
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter): Response
{
$query = new InvoiceArchiveQuery();
$query->setCurrentUser($this->getUser());
@@ -468,36 +468,6 @@ final class InvoiceController extends AbstractController
throw $this->createNotFoundException('Unknown document: ' . $document);
}
#[Route(path: '/document_reload/{document}', name: 'admin_invoice_document_reload', methods: ['GET', 'POST'])]
#[IsGranted('upload_invoice_template')]
public function reloadDocument(string $document, Environment $twig): Response
{
$event = new InvoiceDocumentsEvent($this->service->getDocuments(true));
$this->dispatcher->dispatch($event);
$reloaded = false;
foreach ($event->getInvoiceDocuments() as $doc) {
if ($document === $doc->getId() && $doc->isTwig()) {
$reloaded = true;
try {
$twig->enableAutoReload();
$twig->load('@invoice/' . basename($doc->getFilename()));
$twig->disableAutoReload();
$this->flashSuccess('Reloaded template');
} catch (Exception $ex) {
$this->flashException($ex, 'Failed to reload template: ' . $ex->getMessage());
}
}
}
if (!$reloaded) {
throw $this->createNotFoundException('Unknown document: ' . $document);
}
return $this->redirectToRoute('admin_invoice_document_upload');
}
#[Route(path: '/document_upload', name: 'admin_invoice_document_upload', methods: ['GET', 'POST'])]
#[IsGranted('upload_invoice_template')]
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration): Response

View File

@@ -22,7 +22,7 @@ use App\Security\RolePermissionManager;
use App\Security\RoleService;
use App\User\PermissionService;
use App\Utils\PageSetup;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -46,7 +46,7 @@ final class PermissionController extends AbstractController
#[Route(path: '', name: 'admin_user_permissions', methods: ['GET', 'POST'])]
#[IsGranted('role_permissions')]
public function permissions(EventDispatcherInterface $dispatcher, CsrfTokenManagerInterface $csrfTokenManager, RoleService $roleService)
public function permissions(EventDispatcherInterface $dispatcher, CsrfTokenManagerInterface $csrfTokenManager, RoleService $roleService): Response
{
$all = $this->roleRepository->findAll();
$existing = [];

View File

@@ -32,8 +32,8 @@ use Endroid\QrCode\Encoding\Encoding;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh;
use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeMargin;
use Endroid\QrCode\Writer\PngWriter;
use Psr\EventDispatcher\EventDispatcherInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;

View File

@@ -40,7 +40,7 @@ use App\Repository\TeamRepository;
use App\Utils\Context;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;

View File

@@ -18,6 +18,7 @@ use App\Repository\TimesheetRepository;
use App\Timesheet\TimesheetService;
use App\Utils\PageSetup;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -33,7 +34,7 @@ final class QuickEntryController extends AbstractController
}
#[Route(path: '/{begin}', name: 'quick_entry', methods: ['GET', 'POST'])]
public function quickEntry(Request $request, ?string $begin = null)
public function quickEntry(Request $request, ?string $begin = null): Response
{
$factory = $this->getDateTimeFactory();

View File

@@ -0,0 +1,53 @@
<?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\Customer\CustomerStatisticService;
use App\Reporting\CustomerView\CustomerViewForm;
use App\Reporting\CustomerView\CustomerViewQuery;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class CustomerViewController extends AbstractController
{
#[Route(path: '/reporting/customer_view', name: 'report_customer_view', methods: ['GET', 'POST'])]
#[IsGranted('report:customer')]
#[IsGranted(new Expression("is_granted('budget_any', 'customer')"))]
public function __invoke(Request $request, CustomerStatisticService $service): Response
{
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();
$query = new CustomerViewQuery($dateFactory->createDateTime(), $user);
$form = $this->createFormForGetRequest(CustomerViewForm::class, $query);
$form->submit($request->query->all(), false);
$customers = $service->findCustomersForView($query);
$entries = $service->getCustomerView($user, $customers, $query->getToday());
$byCustomer = [];
foreach ($entries as $entry) {
$customer = $entry->getCustomer();
$byCustomer[$customer->getId()] = $entry;
}
return $this->render('reporting/customer_view.html.twig', [
'entries' => $byCustomer,
'form' => $form->createView(),
'report_title' => 'report_customer_view',
'tableName' => 'customer_view_reporting',
'now' => $dateFactory->createDateTime(),
]);
}
}

View File

@@ -16,6 +16,7 @@ use App\Reporting\ProjectDateRange\ProjectDateRangeForm;
use App\Reporting\ProjectDateRange\ProjectDateRangeQuery;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -24,7 +25,7 @@ final class ProjectDateRangeController extends AbstractController
#[Route(path: '/reporting/project_daterange', name: 'report_project_daterange', methods: ['GET', 'POST'])]
#[IsGranted('report:project')]
#[IsGranted(new Expression("is_granted('budget_any', 'project')"))]
public function __invoke(Request $request, ProjectStatisticService $service)
public function __invoke(Request $request, ProjectStatisticService $service): Response
{
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();

View File

@@ -16,6 +16,7 @@ use App\Reporting\ProjectDetails\ProjectDetailsQuery;
use App\Utils\PageSetup;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -24,7 +25,7 @@ final class ProjectDetailsController extends AbstractController
#[Route(path: '/reporting/project_details', name: 'report_project_details', methods: ['GET'])]
#[IsGranted('report:project')]
#[IsGranted(new Expression("is_granted('details', 'project')"))]
public function __invoke(Request $request, ProjectStatisticService $service)
public function __invoke(Request $request, ProjectStatisticService $service): Response
{
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();

View File

@@ -15,6 +15,7 @@ use App\Reporting\ProjectInactive\ProjectInactiveForm;
use App\Reporting\ProjectInactive\ProjectInactiveQuery;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -23,7 +24,7 @@ final class ProjectInactiveController extends AbstractController
#[Route(path: '/reporting/project_inactive', name: 'report_project_inactive', methods: ['GET', 'POST'])]
#[IsGranted('report:project')]
#[IsGranted(new Expression("is_granted('budget_any', 'project')"))]
public function __invoke(Request $request, ProjectStatisticService $service)
public function __invoke(Request $request, ProjectStatisticService $service): Response
{
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();

View File

@@ -15,6 +15,7 @@ use App\Reporting\ProjectView\ProjectViewForm;
use App\Reporting\ProjectView\ProjectViewQuery;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -23,7 +24,7 @@ final class ProjectViewController extends AbstractController
#[Route(path: '/reporting/project_view', name: 'report_project_view', methods: ['GET', 'POST'])]
#[IsGranted('report:project')]
#[IsGranted(new Expression("is_granted('budget_any', 'project')"))]
public function __invoke(Request $request, ProjectStatisticService $service)
public function __invoke(Request $request, ProjectStatisticService $service): Response
{
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();

View File

@@ -42,7 +42,7 @@ final class ReportUsersMonthController extends AbstractController
{
$data = $this->getData($request, $statisticService, $userRepository);
$content = $this->container->get('twig')->render('reporting/report_user_list_export.html.twig', $data);
$content = $this->renderView('reporting/report_user_list_export.html.twig', $data);
$reader = new Html();
$spreadsheet = $reader->loadFromString($content);

View File

@@ -42,7 +42,7 @@ final class ReportUsersWeekController extends AbstractController
{
$data = $this->getData($request, $statisticService, $userRepository);
$content = $this->container->get('twig')->render('reporting/report_user_list_export.html.twig', $data);
$content = $this->renderView('reporting/report_user_list_export.html.twig', $data);
$reader = new Html();
$spreadsheet = $reader->loadFromString($content);

View File

@@ -19,7 +19,6 @@ use App\Reporting\YearlyUserList\YearlyUserListForm;
use App\Repository\Query\UserQuery;
use App\Repository\UserRepository;
use App\Timesheet\TimesheetStatisticService;
use Exception;
use PhpOffice\PhpSpreadsheet\Reader\Html;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -30,11 +29,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('report:other')]
final class ReportUsersYearController extends AbstractController
{
/**
* @param Request $request
* @return Response
* @throws Exception
*/
#[Route(path: '/year', name: 'report_yearly_users', methods: ['GET', 'POST'])]
public function report(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
{
@@ -44,17 +38,12 @@ final class ReportUsersYearController extends AbstractController
);
}
/**
* @param Request $request
* @return Response
* @throws Exception
*/
#[Route(path: '/year_export', name: 'report_yearly_users_export', methods: ['GET', 'POST'])]
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);
$content = $this->renderView('reporting/report_user_list_monthly_export.html.twig', $data);
$reader = new Html();
$spreadsheet = $reader->loadFromString($content);

View File

@@ -10,10 +10,13 @@
namespace App\Controller\Reporting;
use App\Entity\User;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Model\DailyStatistic;
use App\Reporting\MonthByUser\MonthByUser;
use App\Reporting\MonthByUser\MonthByUserForm;
use Exception;
use PhpOffice\PhpSpreadsheet\Reader\Html;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
@@ -35,6 +38,21 @@ final class UserMonthController extends AbstractUserReportController
return $this->render('reporting/report_by_user.html.twig', $this->getData($request));
}
#[Route(path: '/month_export', name: 'report_user_month_export', methods: ['GET', 'POST'])]
public function export(Request $request): Response
{
$data = $this->getData($request);
$content = $this->renderView('reporting/report_by_user_data.html.twig', $data);
$reader = new Html();
$spreadsheet = $reader->loadFromString($content);
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-user-monthly');
return $writer->getFileResponse($spreadsheet);
}
private function getData(Request $request): array
{
$currentUser = $this->getUser();
@@ -95,6 +113,7 @@ final class UserMonthController extends AbstractUserReportController
'current' => $start,
'next' => $nextMonth,
'previous' => $previousMonth,
'export_route' => 'report_user_month_export',
];
}
}

View File

@@ -9,10 +9,13 @@
namespace App\Controller\Reporting;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Model\DailyStatistic;
use App\Reporting\WeekByUser\WeekByUser;
use App\Reporting\WeekByUser\WeekByUserForm;
use Exception;
use PhpOffice\PhpSpreadsheet\Reader\Html;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
@@ -34,6 +37,21 @@ final class UserWeekController extends AbstractUserReportController
return $this->render('reporting/report_by_user.html.twig', $this->getData($request));
}
#[Route(path: '/week_export', name: 'report_user_week_export', methods: ['GET', 'POST'])]
public function export(Request $request): Response
{
$data = $this->getData($request);
$content = $this->renderView('reporting/report_by_user_data.html.twig', $data);
$reader = new Html();
$spreadsheet = $reader->loadFromString($content);
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-user-weekly');
return $writer->getFileResponse($spreadsheet);
}
private function getData(Request $request): array
{
$currentUser = $this->getUser();
@@ -88,6 +106,7 @@ final class UserWeekController extends AbstractUserReportController
'current' => $start,
'next' => $next,
'previous' => $previous,
'export_route' => 'report_user_week_export',
];
}
}

View File

@@ -11,6 +11,8 @@ namespace App\Controller\Reporting;
use App\Configuration\SystemConfiguration;
use App\Entity\User;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Model\DateStatisticInterface;
use App\Model\MonthlyStatistic;
use App\Reporting\YearByUser\YearByUser;
@@ -18,6 +20,7 @@ use App\Reporting\YearByUser\YearByUserForm;
use DateTime;
use DateTimeInterface;
use Exception;
use PhpOffice\PhpSpreadsheet\Reader\Html;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
@@ -39,6 +42,21 @@ final class UserYearController extends AbstractUserReportController
return $this->render('reporting/report_by_user_year.html.twig', $this->getData($request, $systemConfiguration));
}
#[Route(path: '/year_export', name: 'report_user_year_export', methods: ['GET', 'POST'])]
public function export(Request $request, SystemConfiguration $systemConfiguration): Response
{
$data = $this->getData($request, $systemConfiguration);
$content = $this->renderView('reporting/report_by_user_year_export.html.twig', $data);
$reader = new Html();
$spreadsheet = $reader->loadFromString($content);
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-user-yearly');
return $writer->getFileResponse($spreadsheet);
}
private function getData(Request $request, SystemConfiguration $systemConfiguration): array
{
$currentUser = $this->getUser();
@@ -104,6 +122,7 @@ final class UserYearController extends AbstractUserReportController
'current' => $start,
'next' => $next,
'previous' => $previous,
'export_route' => 'report_user_year_export',
];
}

View File

@@ -0,0 +1,27 @@
<?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\Security;
use App\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
#[Route(path: '/auth/link')]
/**
* @CloudRequired
*/
final class LoginLinkController extends AbstractController
{
#[Route(path: '/check', name: 'link_login_check', methods: ['GET'])]
public function check(): Response
{
return new Response();
}
}

View File

@@ -18,6 +18,7 @@ use App\Form\PasswordResetForm;
use App\User\LoginManager;
use App\User\UserService;
use DateTime;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
@@ -26,7 +27,6 @@ use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[Route(path: '/resetting')]
@@ -108,7 +108,7 @@ final class PasswordResetController extends AbstractController
}
return $this->render('security/password-reset/check_email.html.twig', [
'tokenLifetime' => ceil($this->configuration->getPasswordResetRetryLifetime() / 3600),
'tokenLifetime' => $this->configuration->getPasswordResetRetryLifetime(),
]);
}

View File

@@ -49,13 +49,13 @@ final class SecurityController extends AbstractController
}
#[Route(path: '/login_check', name: 'security_check', methods: ['POST'])]
public function checkAction()
public function checkAction(): Response
{
throw new \RuntimeException('You must configure the check path to be handled by the firewall using form_login in your security firewall configuration.');
}
#[Route(path: '/logout', name: 'logout', methods: ['GET', 'POST'])]
public function logoutAction()
public function logoutAction(): Response
{
throw new \RuntimeException('You must activate the logout in your security firewall configuration.');
}

View File

@@ -17,6 +17,7 @@ use App\Event\EmailSelfRegistrationEvent;
use App\Form\SelfRegistrationForm;
use App\User\LoginManager;
use App\User\UserService;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
@@ -27,7 +28,6 @@ use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[Route(path: '/register')]

View File

@@ -36,7 +36,7 @@ use App\Utils\PageSetup;
use App\Validator\Constraints\ColorChoices;
use App\Validator\Constraints\DateTimeFormat;
use App\Validator\Constraints\TimeFormat;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\CurrencyType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;

View File

@@ -33,7 +33,7 @@ use App\Timesheet\TimesheetService;
use App\Timesheet\TrackingMode\TrackingModeInterface;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
@@ -334,6 +334,13 @@ abstract class TimesheetAbstractController extends AbstractController
$execute = false;
/** @var Timesheet $timesheet */
foreach ($timesheets as $timesheet) {
if ($dto->isReplaceDescription()) {
$timesheet->setDescription($dto->getDescription());
$execute = true;
} elseif($dto->getDescription() !== null && $dto->getDescription() !== '') {
$timesheet->setDescription($timesheet->getDescription() . PHP_EOL . $dto->getDescription());
$execute = true;
}
if ($dto->isReplaceTags()) {
foreach ($timesheet->getTags() as $tag) {
$timesheet->removeTag($tag);
@@ -418,7 +425,7 @@ abstract class TimesheetAbstractController extends AbstractController
]);
}
protected function multiDelete(Request $request)
protected function multiDelete(Request $request): Response
{
$form = $this->getMultiUpdateActionForm();
$form->handleRequest($request);
@@ -446,7 +453,7 @@ abstract class TimesheetAbstractController extends AbstractController
return $this->redirectToRoute($this->getTimesheetRoute());
}
protected function prepareQuery(TimesheetQuery $query)
protected function prepareQuery(TimesheetQuery $query): void
{
$query->setUser($this->getUser());
}

View File

@@ -164,7 +164,7 @@ final class TimesheetTeamController extends TimesheetAbstractController
return $this->multiDelete($request);
}
protected function prepareQuery(TimesheetQuery $query)
protected function prepareQuery(TimesheetQuery $query): void
{
$query->setCurrentUser($this->getUser());
}

View File

@@ -25,7 +25,7 @@ use App\Repository\UserRepository;
use App\User\UserService;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -193,7 +193,7 @@ final class UserController extends AbstractController
#[Route(path: '/export', name: 'user_export', methods: ['GET'])]
#[IsGranted('view_user')]
public function exportAction(Request $request, UserExporter $exporter)
public function exportAction(Request $request, UserExporter $exporter): Response
{
$query = new UserQuery();
$query->setCurrentUser($this->getUser());

View File

@@ -14,6 +14,7 @@ use App\Entity\UserPreference;
use App\Form\Type\LanguageType;
use App\Form\Type\SkinType;
use App\Form\Type\TimezoneType;
use App\Form\UserPasswordType;
use App\User\UserService;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\HttpFoundation\Request;
@@ -58,6 +59,11 @@ final class WizardController extends AbstractController
->setMethod('POST')
->getForm();
$next = 'done';
if ($user->requiresPasswordReset()) {
$next = 'password';
}
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
@@ -72,13 +78,44 @@ final class WizardController extends AbstractController
if ($data['reload'] === '1') {
return $this->redirectToRoute('wizard', ['wizard' => 'profile', '_locale' => $data['language']]);
} else {
return $this->redirectToRoute('wizard', ['wizard' => 'done', '_locale' => $data['language']]);
return $this->redirectToRoute('wizard', ['wizard' => $next, '_locale' => $data['language']]);
}
}
return $this->render('wizard/profile.html.twig', [
'percent' => \intval(100 / \count(User::WIZARDS) * 1),
'previous' => 'intro',
'next' => $next,
'form' => $form->createView(),
]);
}
if ($wizard === 'password' || $user->requiresPasswordReset()) {
$form = $this->createForm(UserPasswordType::class, $user, [
'action' => $this->generateUrl('wizard', ['wizard' => 'password']),
'method' => 'POST',
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user->setRequiresPasswordReset(false);
$userService->updateUser($user);
return $this->redirectToRoute('wizard', ['wizard' => 'done']);
}
$previous = 'profile';
$percent = \intval(100 / \count(User::WIZARDS) * 1);
if ($user->requiresPasswordReset()) {
$previous = null;
$percent = null;
}
return $this->render('wizard/password.html.twig', [
'percent' => $percent,
'previous' => $previous,
'next' => 'done',
'form' => $form->createView(),
]);

View File

@@ -22,7 +22,7 @@ use App\Repository\Query\CustomerQuery;
use App\Utils\NumberGenerator;
use App\Validator\ValidationFailedException;
use InvalidArgumentException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class CustomerService

View File

@@ -11,23 +11,33 @@ namespace App\Customer;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\User;
use App\Event\CustomerBudgetStatisticEvent;
use App\Event\CustomerStatisticEvent;
use App\Model\CustomerBudgetStatisticModel;
use App\Model\CustomerStatistic;
use App\Reporting\CustomerView\CustomerViewModel;
use App\Reporting\CustomerView\CustomerViewQuery;
use App\Repository\CustomerRepository;
use App\Repository\Loader\CustomerLoader;
use App\Repository\TimesheetRepository;
use App\Timesheet\DateTimeFactory;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
/**
* @final
*/
class CustomerStatisticService
{
public function __construct(private TimesheetRepository $timesheetRepository, private EventDispatcherInterface $dispatcher)
public function __construct(
private CustomerRepository $customerRepository,
private TimesheetRepository $timesheetRepository,
private EventDispatcherInterface $dispatcher
)
{
}
@@ -85,6 +95,7 @@ class CustomerStatisticService
$result = $qb->getQuery()->getResult();
if (null !== $result) {
/** @var array{'id': string, 'duration': int, 'internalRate': float, 'counter': int, 'rate': float, 'billable': int, 'exported': int} $resultRow */
foreach ($result as $resultRow) {
$statistic = $statistics[$resultRow['id']];
$statistic->setDuration($statistic->getDuration() + $resultRow['duration']);
@@ -113,12 +124,16 @@ class CustomerStatisticService
return $statistics;
}
/**
* @param array<Customer> $customers
*/
private function createStatisticQueryBuilder(array $customers, DateTime $begin = null, ?DateTime $end = null): QueryBuilder
{
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb
->select('IDENTITY(p.customer) AS id')
->join(Project::class, 'p', Query\Expr\Join::WITH, 't.project = p.id')
->join(Customer::class, 'c', Query\Expr\Join::WITH, 'p.customer = c.id')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->addSelect('COALESCE(SUM(t.internalRate), 0) as internalRate')
@@ -149,4 +164,177 @@ class CustomerStatisticService
return $qb;
}
/**
* @param CustomerViewQuery $query
* @return Customer[]
*/
public function findCustomersForView(CustomerViewQuery $query): array
{
$user = $query->getUser();
$qb = $this->customerRepository->createQueryBuilder('c');
$qb
->select('c')
->andWhere($qb->expr()->eq('c.visible', true))
->addGroupBy('c')
;
if ($query->isIncludeWithBudget()) {
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->gt('c.timeBudget', 0),
$qb->expr()->gt('c.budget', 0)
)
);
} elseif ($query->isIncludeWithoutBudget()) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->eq('c.timeBudget', 0),
$qb->expr()->eq('c.budget', 0)
)
);
}
$this->customerRepository->addPermissionCriteria($qb, $user);
/** @var Customer[] $customers */
$customers = $qb->getQuery()->getResult();
// pre-cache customer objects instead of joining them
$loader = new CustomerLoader($this->customerRepository->createQueryBuilder('c')->getEntityManager(), false);
$loader->loadResults($customers);
return $customers;
}
/**
* @param User $user
* @param Customer[] $customers
* @param DateTime $today
* @return CustomerViewModel[]
*/
public function getCustomerView(User $user, array $customers, DateTime $today): array
{
$today = clone $today;
/** @var array<int, CustomerViewModel> $customerViews */
$customerViews = [];
foreach ($customers as $customer) {
$customerViews[$customer->getId()] = new CustomerViewModel($customer);
}
$budgetStats = $this->getBudgetStatisticModelForCustomers($customers, $today);
foreach ($budgetStats as $model) {
$customerViews[$model->getCustomer()->getId()]->setBudgetStatisticModel($model);
}
$customerIds = array_keys($customerViews);
$tplQb = $this->timesheetRepository->createQueryBuilder('t');
$tplQb
->select('c.id AS id')
->join(Project::class, 'p', Query\Expr\Join::WITH, 't.project = p.id')
->join(Customer::class, 'c', Query\Expr\Join::WITH, 'p.customer = c.id')
->addSelect('COUNT(t.id) as amount')
->addSelect('COALESCE(SUM(t.duration), 0) AS duration')
->addSelect('COALESCE(SUM(t.rate), 0) AS rate')
->andWhere($tplQb->expr()->in('c.id', ':customer'))
->groupBy('id')
->setParameter('customer', array_values($customerIds))
;
$qb = clone $tplQb;
$result = $qb->getQuery()->getScalarResult();
/** @var array{'duration': int, 'amount': int, 'rate': float, 'id': string, 'exported': int} $row */
foreach ($result as $row) {
$customerViews[$row['id']]->setDurationTotal($row['duration']);
$customerViews[$row['id']]->setRateTotal($row['rate']);
$customerViews[$row['id']]->setTimesheetCounter($row['amount']);
}
$qb = clone $tplQb;
$qb
->addSelect('t.exported')
->addSelect('t.billable')
->addGroupBy('t.exported')
->addGroupBy('t.billable')
;
$result = $qb->getQuery()->getScalarResult();
/** @var array{'duration': int, 'billable': int, 'rate': float, 'exported': int, 'id': string} $row */
foreach ($result as $row) {
$view = $customerViews[$row['id']];
if ($row['billable'] === 1 && $row['exported'] === 1) {
$view->setBillableDuration($view->getBillableDuration() + $row['duration']);
$view->setBillableRate($view->getBillableRate() + $row['rate']);
} elseif ($row['billable'] === 1 && $row['exported'] === 0) {
$view->setBillableDuration($view->getBillableDuration() + $row['duration']);
$view->setBillableRate($view->getBillableRate() + $row['rate']);
$view->setNotExportedDuration($view->getNotExportedDuration() + $row['duration']);
$view->setNotExportedRate($view->getNotExportedRate() + $row['rate']);
$view->setNotBilledDuration($view->getNotBilledDuration() + $row['duration']);
$view->setNotBilledRate($view->getNotBilledRate() + $row['rate']);
} elseif ($row['billable'] === 0 && $row['exported'] === 0) {
$view->setNotExportedDuration($view->getNotExportedDuration() + $row['duration']);
$view->setNotExportedRate($view->getNotExportedRate() + $row['rate']);
}
// the last possible case $row['billable'] === 0 && $row['exported'] === 1 is extremely unlikely and not used
}
return array_values($customerViews);
}
/**
* @param Customer[] $customers
* @param DateTime $today
* @return CustomerBudgetStatisticModel[]
*/
public function getBudgetStatisticModelForCustomers(array $customers, DateTime $today): array
{
$models = [];
$monthly = [];
$allTime = [];
foreach ($customers as $customer) {
$models[$customer->getId()] = new CustomerBudgetStatisticModel($customer);
if ($customer->isMonthlyBudget()) {
$monthly[] = $customer;
} else {
$allTime[] = $customer;
}
}
$statisticsTotal = $this->getBudgetStatistic($customers);
foreach ($statisticsTotal as $id => $statistic) {
$models[$id]->setStatisticTotal($statistic);
}
$dateFactory = new DateTimeFactory($today->getTimezone());
$begin = null;
$end = $today;
if (\count($monthly) > 0) {
$begin = $dateFactory->getStartOfMonth($today);
$end = $dateFactory->getEndOfMonth($today);
$statistics = $this->getBudgetStatistic($monthly, $begin, $end);
foreach ($statistics as $id => $statistic) {
$models[$id]->setStatistic($statistic);
}
}
if (\count($allTime) > 0) {
// display the budget at the end of the selected period and not the total sum of all times (do not include times in the future)
$statistics = $this->getBudgetStatistic($allTime, null, $today);
foreach ($statistics as $id => $statistic) {
$models[$id]->setStatistic($statistic);
}
}
$event = new CustomerBudgetStatisticEvent($models, $begin, $end);
$this->dispatcher->dispatch($event);
return $models;
}
}

View File

@@ -105,7 +105,7 @@ final class Configuration implements ConfigurationInterface
return $node;
}
private function getProjectNode()
private function getProjectNode(): ArrayNodeDefinition
{
$builder = new TreeBuilder('project');
/** @var ArrayNodeDefinition $node */
@@ -343,7 +343,7 @@ final class Configuration implements ConfigurationInterface
->defaultValue('{Y}/{cy,3}')
->end()
->booleanNode('upload_twig')
->defaultTrue()
->defaultFalse()
->end()
->end()
;

View File

@@ -52,7 +52,7 @@ abstract class AbstractMigration extends BaseAbstractMigration
*
* @throws Exception
*/
protected function abortIfPlatformNotSupported()
protected function abortIfPlatformNotSupported(): void
{
$platform = $this->connection->getDatabasePlatform();
if (!($platform instanceof MySQLPlatform)) {

View File

@@ -0,0 +1,50 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Doctrine;
use App\Entity\Timesheet;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
/**
* Automatically set the modifiedAt field for all Timesheet entries.
*/
#[AsDoctrineListener(event: Events::onFlush, priority: 60)]
final class ModifiedSubscriber implements EventSubscriber, DataSubscriberInterface
{
public function getSubscribedEvents(): array
{
return [
Events::onFlush,
];
}
public function onFlush(OnFlushEventArgs $args): void
{
$uow = $args->getObjectManager()->getUnitOfWork();
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!($entity instanceof Timesheet)) {
continue;
}
$entity->setModifiedAt($now);
}
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!($entity instanceof Timesheet)) {
continue;
}
$entity->setModifiedAt($now);
}
}
}

View File

@@ -11,6 +11,7 @@ namespace App\Doctrine;
use App\Entity\Timesheet;
use App\Timesheet\CalculatorInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
@@ -18,6 +19,7 @@ use Doctrine\ORM\Events;
/**
* A listener to make sure all Timesheet entries will be calculated properly (e.g. duration and rates).
*/
#[AsDoctrineListener(event: Events::onFlush, priority: 50)]
final class TimesheetSubscriber implements EventSubscriber, DataSubscriberInterface
{
/**

View File

@@ -44,7 +44,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
#[Exporter\Expose(label: 'id', type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: true)]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
#[Serializer\Expose]
#[Serializer\Groups(['Subresource', 'Expanded'])]
#[OA\Property(ref: '#/components/schemas/ProjectExpanded')]
@@ -87,7 +87,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
*
* @var Collection<ActivityMeta>
*/
#[ORM\OneToMany(targetEntity: ActivityMeta::class, mappedBy: 'activity', cascade: ['persist'])]
#[ORM\OneToMany(mappedBy: 'activity', targetEntity: ActivityMeta::class, cascade: ['persist'])]
#[Serializer\Expose]
#[Serializer\Groups(['Activity'])]
#[Serializer\Type(name: 'array<App\Entity\ActivityMeta>')]
@@ -102,7 +102,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
#[ORM\JoinTable(name: 'kimai2_activities_teams')]
#[ORM\JoinColumn(name: 'activity_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'team_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\ManyToMany(targetEntity: Team::class, cascade: ['persist'], inversedBy: 'activities')]
#[ORM\ManyToMany(targetEntity: Team::class, inversedBy: 'activities', cascade: ['persist'])]
#[Serializer\Expose]
#[Serializer\Groups(['Activity'])]
#[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Team'))]
@@ -232,7 +232,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
return $this;
}
public function addTeam(Team $team)
public function addTeam(Team $team): void
{
if ($this->teams->contains($team)) {
return;
@@ -242,7 +242,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
$team->addActivity($this);
}
public function removeTeam(Team $team)
public function removeTeam(Team $team): void
{
if (!$this->teams->contains($team)) {
return;

View File

@@ -23,7 +23,7 @@ class ActivityMeta implements MetaTableTypeInterface
use MetaTableTypeTrait;
#[ORM\ManyToOne(targetEntity: Activity::class, inversedBy: 'meta')]
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private ?Activity $activity = null;

View File

@@ -25,7 +25,7 @@ class ActivityRate implements RateInterface
use Rate;
#[ORM\ManyToOne(targetEntity: Activity::class)]
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private ?Activity $activity = null;

View File

@@ -29,7 +29,7 @@ class Bookmark
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private ?User $user = null;
#[ORM\Column(name: 'type', type: 'string', length: 20, nullable: false)]

View File

@@ -15,17 +15,17 @@ interface CommentInterface
public function getMessage(): ?string;
public function setMessage(string $message);
public function setMessage(string $message): void;
public function getCreatedBy(): ?User;
public function setCreatedBy(User $createdBy);
public function setCreatedBy(User $createdBy): void;
public function getCreatedAt(): ?\DateTime;
public function setCreatedAt(\DateTime $createdAt);
public function setCreatedAt(\DateTime $createdAt): void;
public function isPinned(): bool;
public function setPinned(bool $pinned);
public function setPinned(bool $pinned): void;
}

View File

@@ -22,7 +22,7 @@ trait CommentTableTypeTrait
#[Assert\NotNull]
private ?string $message = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private ?User $createdBy = null;
#[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)]

View File

@@ -156,7 +156,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
*
* @var Collection<CustomerMeta>
*/
#[ORM\OneToMany(targetEntity: CustomerMeta::class, mappedBy: 'customer', cascade: ['persist'])]
#[ORM\OneToMany(mappedBy: 'customer', targetEntity: CustomerMeta::class, cascade: ['persist'])]
#[Serializer\Expose]
#[Serializer\Groups(['Customer'])]
#[Serializer\Type(name: 'array<App\Entity\CustomerMeta>')]
@@ -171,7 +171,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
#[ORM\JoinTable(name: 'kimai2_customers_teams')]
#[ORM\JoinColumn(name: 'customer_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'team_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\ManyToMany(targetEntity: Team::class, cascade: ['persist'], inversedBy: 'customers')]
#[ORM\ManyToMany(targetEntity: Team::class, inversedBy: 'customers', cascade: ['persist'])]
#[Serializer\Expose]
#[Serializer\Groups(['Customer'])]
#[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Team'))]
@@ -180,7 +180,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
* Default invoice template for this customer
*/
#[ORM\ManyToOne(targetEntity: InvoiceTemplate::class)]
#[ORM\JoinColumn(onDelete: 'SET NULL', nullable: true)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?InvoiceTemplate $invoiceTemplate = null;
#[ORM\Column(name: 'invoice_text', type: 'text', nullable: true)]
private ?string $invoiceText = null;
@@ -440,7 +440,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
return $this;
}
public function addTeam(Team $team)
public function addTeam(Team $team): void
{
if ($this->teams->contains($team)) {
return;
@@ -450,7 +450,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
$team->addCustomer($this);
}
public function removeTeam(Team $team)
public function removeTeam(Team $team): void
{
if (!$this->teams->contains($team)) {
return;

View File

@@ -21,7 +21,7 @@ class CustomerComment implements CommentInterface
use CommentTableTypeTrait;
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private Customer $customer;

View File

@@ -23,7 +23,7 @@ class CustomerMeta implements MetaTableTypeInterface
use MetaTableTypeTrait;
#[ORM\ManyToOne(targetEntity: Customer::class, inversedBy: 'meta')]
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private ?Customer $customer = null;

View File

@@ -25,7 +25,7 @@ class CustomerRate implements RateInterface
use Rate;
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private ?Customer $customer = null;

View File

@@ -56,11 +56,11 @@ class Invoice implements EntityWithMetaFields
#[Exporter\Expose(label: 'comment')]
private ?string $comment = null;
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private ?Customer $customer = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private ?User $user = null;
#[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)]
@@ -109,7 +109,7 @@ class Invoice implements EntityWithMetaFields
*
* @var Collection<InvoiceMeta>
*/
#[ORM\OneToMany(targetEntity: InvoiceMeta::class, mappedBy: 'invoice', cascade: ['persist'])]
#[ORM\OneToMany(mappedBy: 'invoice', targetEntity: InvoiceMeta::class, cascade: ['persist'])]
#[Serializer\Expose]
#[Serializer\Groups(['Invoice'])]
#[Serializer\Type(name: 'array<App\Entity\InvoiceMeta>')]

View File

@@ -23,7 +23,7 @@ class InvoiceMeta implements MetaTableTypeInterface
use MetaTableTypeTrait;
#[ORM\ManyToOne(targetEntity: Invoice::class, inversedBy: 'meta')]
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private ?Invoice $invoice = null;

View File

@@ -48,7 +48,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
* Customer for this project
*/
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
#[Serializer\Expose]
#[Serializer\Groups(['Subresource', 'Expanded'])]
@@ -137,7 +137,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
*
* @var Collection<ProjectMeta>
*/
#[ORM\OneToMany(targetEntity: ProjectMeta::class, mappedBy: 'project', cascade: ['persist'])]
#[ORM\OneToMany(mappedBy: 'project', targetEntity: ProjectMeta::class, cascade: ['persist'])]
#[Serializer\Expose]
#[Serializer\Groups(['Project'])]
#[Serializer\Type(name: 'array<App\Entity\ProjectMeta>')]
@@ -152,7 +152,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
#[ORM\JoinTable(name: 'kimai2_projects_teams')]
#[ORM\JoinColumn(name: 'project_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'team_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\ManyToMany(targetEntity: Team::class, cascade: ['persist'], inversedBy: 'projects')]
#[ORM\ManyToMany(targetEntity: Team::class, inversedBy: 'projects', cascade: ['persist'])]
#[Serializer\Expose]
#[Serializer\Groups(['Project'])]
#[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Team'))]
@@ -253,7 +253,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
* Make sure begin and end date have the correct timezone.
* This will be called once for each item after being loaded from the database.
*/
protected function localizeDates()
protected function localizeDates(): void
{
if ($this->localized) {
return;
@@ -392,7 +392,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
return $this;
}
public function addTeam(Team $team)
public function addTeam(Team $team): void
{
if ($this->teams->contains($team)) {
return;
@@ -402,7 +402,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget
$team->addProject($this);
}
public function removeTeam(Team $team)
public function removeTeam(Team $team): void
{
if (!$this->teams->contains($team)) {
return;

View File

@@ -21,7 +21,7 @@ class ProjectComment implements CommentInterface
use CommentTableTypeTrait;
#[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private Project $project;

View File

@@ -23,7 +23,7 @@ class ProjectMeta implements MetaTableTypeInterface
use MetaTableTypeTrait;
#[ORM\ManyToOne(targetEntity: Project::class, inversedBy: 'meta')]
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private ?Project $project = null;

View File

@@ -25,7 +25,7 @@ class ProjectRate implements RateInterface
use Rate;
#[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private ?Project $project = null;

View File

@@ -23,7 +23,7 @@ trait Rate
#[Serializer\Groups(['Default'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: true)]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[OA\Property(ref: '#/components/schemas/User')]

View File

@@ -25,7 +25,7 @@ class RolePermission
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Role::class)]
#[ORM\JoinColumn(onDelete: 'CASCADE', nullable: false)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private ?Role $role = null;
#[ORM\Column(name: 'permission', type: 'string', length: 50, nullable: false)]

View File

@@ -39,7 +39,7 @@ class Tag
#[ORM\Column(name: 'name', type: 'string', length: 100, nullable: false)]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100, normalizer: 'trim')]
#[Assert\Regex(pattern: '/,/', match: false, message: 'Tag name cannot contain comma')]
#[Assert\Regex(pattern: '/,/', message: 'Tag name cannot contain comma', match: false)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private ?string $name = null;

View File

@@ -47,7 +47,7 @@ class Team
*
* @var Collection<TeamMember>
*/
#[ORM\OneToMany(targetEntity: TeamMember::class, mappedBy: 'team', fetch: 'LAZY', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OneToMany(mappedBy: 'team', targetEntity: TeamMember::class, cascade: ['persist', 'remove'], fetch: 'LAZY', orphanRemoval: true)]
#[ORM\JoinColumn(onDelete: 'CASCADE')]
#[Assert\Count(min: 1)]
#[Serializer\Expose]
@@ -59,7 +59,7 @@ class Team
*
* @var Collection<Customer>
*/
#[ORM\ManyToMany(targetEntity: Customer::class, mappedBy: 'teams', fetch: 'EXTRA_LAZY', cascade: ['persist'])]
#[ORM\ManyToMany(targetEntity: Customer::class, mappedBy: 'teams', cascade: ['persist'], fetch: 'EXTRA_LAZY')]
#[Serializer\Expose]
#[Serializer\Groups(['Team_Entity'])]
#[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Customer'))]
@@ -69,7 +69,7 @@ class Team
*
* @var Collection<Project>
*/
#[ORM\ManyToMany(targetEntity: Project::class, mappedBy: 'teams', fetch: 'EXTRA_LAZY', cascade: ['persist'])]
#[ORM\ManyToMany(targetEntity: Project::class, mappedBy: 'teams', cascade: ['persist'], fetch: 'EXTRA_LAZY')]
#[Serializer\Expose]
#[Serializer\Groups(['Team_Entity', 'Expanded'])]
#[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Project'))]
@@ -79,7 +79,7 @@ class Team
*
* @var Collection<Activity>
*/
#[ORM\ManyToMany(targetEntity: Activity::class, mappedBy: 'teams', fetch: 'EXTRA_LAZY', cascade: ['persist'])]
#[ORM\ManyToMany(targetEntity: Activity::class, mappedBy: 'teams', cascade: ['persist'], fetch: 'EXTRA_LAZY')]
#[Serializer\Expose]
#[Serializer\Groups(['Team_Entity', 'Expanded'])]
#[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Activity'))]

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