Compare commits

..

3 Commits

Author SHA1 Message Date
Kevin Papst
2d5cef1a2f phpstan 2024-10-12 00:00:46 +02:00
Kevin Papst
52a9ff9860 Merge branch 'refs/heads/main' into activity-report
# Conflicts:
#	config/packages/kimai.yaml
#	phpstan.neon
#	src/Activity/ActivityStatisticService.php
#	src/Repository/ActivityRepository.php
2024-10-11 23:45:40 +02:00
Kevin Papst
ecded0a065 added activity overview report 2023-11-19 16:10:32 +01:00
411 changed files with 9017 additions and 9188 deletions

View File

@@ -13,19 +13,20 @@ try {
]);
} catch(\Exception $ex) {
switch ($ex->getCode()) {
// we can immediately stop startup here and show the error message
case 1045:
// we can immediately stop here and show the error message
echo 'Access denied (1045)';
die(1);
// we can immediately stop startup here and show the error message
case 1049:
// error "Unknown database (1049)" can be ignored, the database will be created by Kimai
return;
echo 'Unknown database (1049)';
die(2);
// a lot of errors share the same meaningless error code zero
case 0:
// this error includes the database name, so we can only search for the static part of the error message
if (stripos($ex->getMessage(), 'SQLSTATE[HY000] [1049] Unknown database') !== false) {
// error "Unknown database (1049)" can be ignored, the database will be created by Kimai
return;
echo 'Unknown database (0-1049)';
die(3);
}
switch ($ex->getMessage()) {
// eg. no response (fw) - the startup script should retry it a couple of times

View File

@@ -76,6 +76,7 @@ function handleStartup() {
function prepareKimai() {
# These are idempotent, so we can run them on every start-up
/opt/kimai/bin/console -n kimai:install
/opt/kimai/bin/console -n kimai:update
if [ ! -z "$ADMINPASS" ] && [ ! -a "$ADMINMAIL" ]; then
/opt/kimai/bin/console kimai:user:create admin "$ADMINMAIL" ROLE_SUPER_ADMIN "$ADMINPASS"
fi

18
.gitattributes vendored
View File

@@ -1,18 +0,0 @@
.docker export-ignore
.github export-ignore
assets export-ignore
tests export-ignore
.codecov.yml export-ignore
.editorconfig export-ignore
.eslintrc.js export-ignore
.gitattributes export-ignore
.gitignore export-ignore
php-cs-fixer.dist.php export-ignore
babel.config.js export-ignore
package.json export-ignore
php-cs-fixer.sh export-ignore
phpstan.neon export-ignore
phpstan.sh export-ignore
phpunit.xml.dist export-ignore
webpack.config.js export-ignore
yarn.lock export-ignore

View File

@@ -19,7 +19,7 @@ jobs:
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
strategy:
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
php: ['8.1', '8.2', '8.3']
name: Integration (${{ matrix.php }})
steps:

1
.gitignore vendored
View File

@@ -18,7 +18,6 @@
# for keeping empty directories
/config/packages/local.yaml
/config/bundles-local.php
/var/dev/*
/var/data/*
/var/cache/*
/var/invoices*

View File

@@ -1,16 +1,15 @@
# Contributing
Kimai is an open source project, contributions made by the community are welcome.
But we can only accept contributions with a signed CLA (Contributor License Agreement) to prevent issues in the future (you will see a link when opening a PR).
Kimai is an open source project, contributions made by the community are welcome.
Send your ideas, code reviews, pull requests and feature requests to help to improve this project.
## Pull request rules
- Make your changes in a new git branch, based on the latest code in `main`
- Apply our code-style by running `composer codestyle-fix`
- Run the static code analysis with `composer phpstan`
- Verify everything still works with `composer tests-unit`
- Verify everything still works with `composer tests-unit` and `composer tests-integration`
- Add tests for your changes
- When sending in a PR, you must accept that your contributions/code will be published under MIT license (see the [LICENSE](LICENSE) file as well), otherwise your PR will be closed
- If one of the PR checks/builds fails, fix it before asking for a review
Further documentation can be found in the [developer documentation](https://www.kimai.org/documentation/developers.html).

View File

@@ -218,18 +218,20 @@ COPY --from=php-ext-intl /usr/local/lib/php/extensions/no-debug-non-zts-20230831
COPY --from=php-ext-opcache /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini
###########################
# fetch Kimai sources
# Shared tools
###########################
FROM alpine:latest AS git-prod
# full kimai source
FROM alpine:latest AS git-dev
ARG KIMAI
ARG TIMEZONE
# the convention in the Kimai repository is: tags are always version numbers, branch names always start with a letter
# if the KIMAI variable starts with a number (e.g. 2.24.0) we assume its a tag, otherwise its a branch
RUN [[ $KIMAI =~ ^[0-9] ]] && export REF='tags' || export REF='heads' && \
wget -O "/opt/kimai.tar.gz" "https://github.com/kimai/kimai/archive/refs/${REF}/${KIMAI}.tar.gz" && \
tar -xpzf /opt/kimai.tar.gz -C /opt/ && \
mv /opt/kimai-${KIMAI} /opt/kimai
RUN apk add --no-cache git && \
git clone --depth 1 --branch ${KIMAI} https://github.com/kimai/kimai.git /opt/kimai
# production kimai source
FROM git-dev AS git-prod
WORKDIR /opt/kimai
RUN rm -r tests
###########################
# global base build
@@ -284,9 +286,10 @@ CMD [ "/entrypoint.sh" ]
# development build
FROM base AS dev
# copy kimai develop source
COPY --from=git-prod --chown=www-data:www-data /opt/kimai /opt/kimai
COPY --from=git-dev --chown=www-data:www-data /opt/kimai /opt/kimai
COPY .docker /assets
# do the composer deps installation
RUN echo \$PATH
RUN \
export COMPOSER_HOME=/composer && \
composer --no-ansi install --working-dir=/opt/kimai --optimize-autoloader && \
@@ -302,7 +305,7 @@ ENV APP_ENV=dev
ENV DATABASE_URL=
ENV memory_limit=512M
# the "prod" stage (production build) is configured as last stage in the file, as this is the default target in BuildKit
# production build
FROM base AS prod
# copy kimai production source
COPY --from=git-prod --chown=www-data:www-data /opt/kimai /opt/kimai

View File

@@ -7,4 +7,5 @@ As announced in the [README](README.md) security fixes will only be added to the
| main branch | :white_check_mark: |
| older releases | :x: |
You find all information in our [Bughunter documentation](https://www.kimai.org/documentation/bughunter.html).

0
assets/.gitignore vendored Normal file
View File

View File

@@ -48,10 +48,10 @@ export default class KimaiCopyDataForm extends KimaiFormPlugin {
if (element.dataset.event !== undefined) {
for (const event of element.dataset.event.split(' ')) {
target.dispatchEvent(new Event(event));
const form = target.closest('form');
if (form !== null) {
form.dispatchEvent(new Event(event));
}
}
} else if (element.dataset.eventBubbles !== undefined) {
for (const event of element.dataset.eventBubbles.split(' ')) {
target.dispatchEvent(new Event(event, {bubbles: true}));
}
}
event.preventDefault();

View File

@@ -15,7 +15,7 @@ export default class KimaiMultiUpdateTable extends KimaiPlugin {
init()
{
if (document.getElementsByClassName('multi_update_all').length === 0) {
if (document.getElementById('multi_update_all') === null) {
return;
}
@@ -23,64 +23,64 @@ export default class KimaiMultiUpdateTable extends KimaiPlugin {
// via KimaiDatable and everything inside will be removed, including event listeners
const element = document.querySelector('div.page-body');
element.addEventListener('change', (event) => {
if (event.target.matches('.multi_update_all')) {
if (event.target.matches('#multi_update_all')) {
// the "check all" checkbox in the upper start corner of the table
const checked = event.target.checked;
const table = event.target.closest('table');
for (const element of table.querySelectorAll('.multi_update_single')) {
for (const element of document.querySelectorAll('.multi_update_single')) {
element.checked = checked;
}
this._toggleForm(table);
this._toggleForm();
event.stopPropagation();
} else if (event.target.matches('.multi_update_single')) {
// single checkboxes in front of each row
this._toggleForm(event.target.closest('table'));
this._toggleForm();
event.stopPropagation();
}
});
element.addEventListener('click', (event) => {
if (event.target.matches('.multi_update_table_action')) {
const selectedButton = event.target;
const form = selectedButton.form;
const ids = form.querySelector('.multi_update_ids').value.split(',');
const question = form.dataset['question'].replace(/%action%/, selectedButton.textContent).replace(/%count%/, ids.length.toString());
const selectedItem = event.target;
const ids = this._getSelectedIds();
const form = document.getElementById('multi_update_form');
const question = form.dataset['question'].replace(/%action%/, selectedItem.textContent).replace(/%count%/, ids.length.toString());
/** @type {KimaiAlert} ALERT */
const ALERT = this.getPlugin('alert');
ALERT.question(question, function(value) {
if (value) {
form.action = selectedButton.dataset['href'];
const form = document.getElementById('multi_update_form');
form.action = selectedItem.dataset['href'];
form.submit();
}
});
}
});
}
/**
* @param {HTMLTableElement} table
* @private
*/
_toggleForm(table)
_getSelectedIds()
{
const card = table.closest('div.card.data_table')
let ids = [];
for (const box of table.querySelectorAll('input.multi_update_single:checked')) {
for (const box of document.querySelectorAll('input.multi_update_single:checked')) {
ids.push(box.value);
}
card.querySelector('.multi_update_ids').value = ids.join(',');
return ids;
}
_toggleForm()
{
const ids = this._getSelectedIds();
document.getElementById('multi_update_table_entities').value = ids.join(',');
if (ids.length > 0) {
for (const element of card.querySelectorAll('.multi_update_form_hide')) {
for (const element of document.getElementsByClassName('multi_update_form_hide')) {
element.style.setProperty('display', 'none', 'important');
}
card.querySelector('form.multi_update_form').style.display = null;//'block';
document.getElementById('multi_update_form').style.display = null;//'block';
} else {
card.querySelector('form.multi_update_form').style.setProperty('display', 'none', 'important');
for (const element of card.querySelectorAll('.multi_update_form_hide')) {
document.getElementById('multi_update_form').style.setProperty('display', 'none', 'important');
for (const element of document.getElementsByClassName('multi_update_form_hide')) {
element.style.display = null;
}
}

View File

@@ -5,30 +5,111 @@
* file that was distributed with this source code.
*/
body { font-family: sans-serif; font-size: 10pt; margin: 4px; padding: 0; }
table, tr, td, th, p, h1, h2, h3, h4, h5 { padding: 0; margin: 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-begin { text-align: left; }
.text-right, .text-end { text-align: right; }
.text-nowrap { white-space: nowrap; }
.date { font-size: 80%; text-align: right; padding-top: 6px; }
table { width: 100%; border-spacing: 0; }
table.addresses, p { line-height: 14pt; }
table.addresses { margin-bottom: 30px; }
table.header { padding-bottom: 4px; border-bottom: 1px solid #999; }
table.header .title { font-weight: normal; 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 thead th, table.items tbody td { border-bottom: 1px solid #ccc; }
table.items thead th.first, table.items tbody td.first { padding-left: 0; }
table.items thead th.last, table.items tbody td.last, table.items tfoot td.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 0; line-height: 1px; }
table.items tfoot td.last { padding-left: 10px; }
table.items tr.odd { background-color: #f5f5f5; }
body {
font-family: sans-serif;
font-size: 10pt;
margin: 4px;
padding: 0;
}
table, tr, td, th, p, h1, h2, h3, h4, h5 {
padding: 0;
margin: 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-begin {
text-align: left;
}
.text-right, .text-end {
text-align: right;
}
.text-nowrap {
white-space: nowrap;
}
.date {
font-size: 80%;
text-align: right;
padding-top: 6px;
}
table {
width: 100%;
border-spacing: 0;
}
table.addresses, p {
line-height: 14pt;
}
/* The address table */
table.addresses {
margin-bottom: 30px;
}
/* The PDF header */
table.header {
padding-bottom: 4px;
border-bottom: 1px solid #999;
}
table.header .title {
font-weight: normal;
font-size: 140%;
}
/* The PDF footer */
table.footer {
border-top: 1px solid #999;
font-size: 80%;
padding-top: 8px;
line-height: 14px;
}
table.footer td {
vertical-align: top;
}
/* The invoice items list */
table.items {
border-collapse: collapse;
margin-top: 50px;
margin-bottom: 60px;
}
table.items thead th,
table.items tbody td {
border-bottom: 1px solid #ccc;
}
table.items thead th.first,
table.items tbody td.first {
padding-left: 0;
}
table.items thead th.last,
table.items tbody td.last,
table.items tfoot td.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 0;
line-height: 1px;
}
table.items tfoot td.last {
padding-left: 10px;
}
table.items tr.odd {
background-color: #f5f5f5;
}

View File

@@ -49,14 +49,3 @@ fieldset:empty {
--litepicker-is-in-range-color: var(--tblr-primary-text-emphasis);
}
}
/* fixes contrast of batch-update checkboxes - https://github.com/kimai/kimai/issues/5146 */
.multiupdater[type=checkbox]
{
--tblr-border-color-translucent: rgba(4, 32, 69, .2);
}
/* fixes height for long tags https://github.com/kimai/kimai/issues/5169 */
.tag {
--tblr-tag-height: unset;
}

View File

@@ -98,7 +98,7 @@ table.dataTable {
width: 15px;
}
&.overlapping {
border-top: 2px solid rgba(214,57,57,.2);
border-top: 2px solid rgba(214,57,57,.1);
}
&.exported {
opacity: 0.7;

View File

@@ -14,7 +14,7 @@
}
],
"require": {
"php": "8.1.*||8.2.*||8.3.*||8.4.*",
"php": "8.1.*||8.2.*||8.3.*",
"ext-gd": "*",
"ext-intl": "*",
"ext-json": "*",
@@ -61,7 +61,6 @@
"symfony/mailer": "^6.0",
"symfony/mime": "^6.0",
"symfony/monolog-bundle": "^3.4",
"symfony/process": "^6.0",
"symfony/rate-limiter": "^6.0",
"symfony/runtime": "^6.0",
"symfony/security-bundle": "^6.0",
@@ -84,12 +83,12 @@
"doctrine/doctrine-fixtures-bundle": "^3.2",
"fakerphp/faker": "^1.15",
"friendsofphp/php-cs-fixer": "^3.3",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpstan/phpstan-symfony": "^2.0",
"phpstan/phpstan": "^1.0",
"phpstan/phpstan-deprecation-rules": "^1.0",
"phpstan/phpstan-doctrine": "^1.0",
"phpstan/phpstan-phpunit": "^1.0",
"phpstan/phpstan-strict-rules": "^1.0",
"phpstan/phpstan-symfony": "^1.0",
"phpunit/phpunit": "9.5.*",
"symfony/browser-kit": "^6.0",
"symfony/css-selector": "^6.0",
@@ -135,10 +134,6 @@
"symfony/polyfill-ctype": "*",
"symfony/polyfill-mbstring": "*",
"symfony/polyfill-intl": "*",
"symfony/polyfill-intl-icu": "*",
"symfony/polyfill-intl-grapheme": "*",
"symfony/polyfill-intl-idn": "*",
"symfony/polyfill-intl-normalizer": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php80": "*",

1841
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -104,7 +104,7 @@ kimai:
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','project_reporting','customer_reporting']
REPORTING: ['view_reporting','view_other_reporting','activity_reporting','project_reporting','customer_reporting']
EVERYONE: ['api_access','hours_own_profile']
# permissions which are deactivated, as these features are hidden for now
# brave users can try to activate them and be surprised what happens

View File

@@ -5,5 +5,7 @@ twig:
- 'form/vertical.html.twig'
exception_controller: null
paths:
# used in templates/emails/layout.html.twig
'%kernel.project_dir%/assets/css': css
'%kernel.project_dir%/templates/bundles/TablerBundle': theme
'%kernel.project_dir%/vendor/kevinpapst/tabler-bundle/templates': theme

116
kimai.sh
View File

@@ -1,116 +0,0 @@
#!/bin/bash
# --------------------------------------------------------------------------------
# This script was added with 2.24.0 and is in BETA status.
#
# To improve this script across platforms I need your feedback!
# --------------------------------------------------------------------------------
function update_kimai() {
if [[ "$1" =~ ^([0-9]+\.){2,3}[0-9]+$ ]]; then
export VERSION=$1
else
echo "You need to supply a full Kimai version like: \"2.24.0\""
exit 1
fi
git checkout -- composer.json
git checkout -- composer.lock
git checkout -- symfony.lock
if [[ -n $(git status --porcelain) ]]; then
echo "Cannot update: file changes detected. Run \"git status\" for details."
exit 1
fi
rm -rf var/cache/* 2>&1
git fetch --tags
git checkout "$VERSION"
$KIMAI_PHP "$KIMAI_COMPOSER" install --no-dev --optimize-autoloader
$KIMAI_PHP bin/console kimai:install
install_plugins
if [[ -z "${KIMAI_NO_PERMS}" ]]; then
set_permission
fi
}
function install_plugins() {
# detect if there are additional plugins that we need to install
packages="$($PHP bin/console kimai:plugin --composer)"
export PACKAGES=$packages
if [ -n "$PACKAGES" ]; then
$KIMAI_PHP "$KIMAI_COMPOSER" require "$PACKAGES"
$KIMAI_PHP bin/console kimai:plugins --install
fi
}
function set_permission() {
chown -R "$KIMAI_USER":"$KIMAI_GROUP" .
chmod -R g+r .
chmod -R g+rw var/
}
if [[ -z "${KIMAI_USER}" ]]; then
export KIMAI_USER=""
fi
if [[ -z "${KIMAI_GROUP}" ]]; then
export KIMAI_GROUP="www-data"
fi
if [[ -z "${KIMAI_PHP}" ]]; then
export KIMAI_PHP="php"
fi
if [[ -z "${KIMAI_COMPOSER}" ]]; then
export KIMAI_COMPOSER="composer"
fi
cd "$(dirname "$0")" || { echo "Cannot change working directory."; exit 1; }
# we need a few commands installed in order for this script to complete
command -v $KIMAI_COMPOSER >/dev/null 2>&1 || { echo >&2 "Update requires 'composer' but it's not installed."; exit 1; }
command -v git >/dev/null 2>&1 || { echo >&2 "Update requires 'git' but it's not installed."; exit 1; }
command -v $KIMAI_PHP >/dev/null 2>&1 || { echo >&2 "Update requires 'php' but it's not installed."; exit 1; }
if [[ -n $1 ]]; then
if [ "$1" == 'update' ]; then
update_kimai "$2"
exit
elif [ "$1" == 'permission' ]; then
set_permission
exit
elif [ "$1" == 'plugins' ]; then
install_plugins
exit
else
echo ""
echo ">> Unknown command: $1"
fi
fi
echo ""
echo "This script has the following sub-commands:"
echo ""
echo "$0 update <version> - Install Kimai version <version>"
echo "$0 permission - Fix file permissions"
echo "$0 plugins - Install plugins from var/packages/*.zip"
echo ""
echo "Use the following environment variables to customize the runtime:"
echo ""
echo "KIMAI_USER - Username of the webserver/php process that needs write access"
echo "KIMAI_GROUP - Group of the webserver/php process that needs write access"
echo "KIMAI_PHP - Full path to PHP executable in the correct version"
echo "KIMAI_COMPOSER - Path to composer executable or .phar file"
echo "KIMAI_NO_PERMS - Skip changing permissions"
echo ""
echo "Examples:"
echo ""
echo "$0 2.24.0"
echo "KIMAI_PHP=/usr/bin/php8.3 $0 2.24.0"
echo "KIMAI_PHP=/usr/bin/php8.3 KIMAI_COMPOSER=/tmp/composer.phar $0 2.24.0"
echo "KIMAI_PHP=php8.3 KIMAI_GROUP=httpd $0 2.24.0"
echo "KIMAI_NO_PERMS=1 KIMAI_PHP=/usr/bin/php8.3 $0 2.24.0"
echo ""

View File

@@ -24,9 +24,9 @@
],
"packageManager": "yarn@3.2.1",
"devDependencies": {
"@babel/core": "^7.26",
"@babel/core": "^7.19",
"@babel/eslint-parser": "^7.19",
"@babel/preset-env": "^7.26",
"@babel/preset-env": "^7.19",
"@fortawesome/fontawesome-free": "^6.5",
"@fullcalendar/bootstrap5": "^5.11",
"@fullcalendar/core": "^5.11",
@@ -47,10 +47,7 @@
"sass": "^1.62",
"sass-loader": "^13.2",
"tom-select": "^2.2",
"webpack": "^5.96",
"webpack": "^5.91",
"webpack-cli": "^4.10"
},
"dependencies": {
"@babel/plugin-syntax-dynamic-import": "^7.8.3"
}
}

View File

@@ -21,10 +21,12 @@ parameters:
booleansInConditions: false
uselessCast: true
requireParentConstructorCall: true
disallowedConstructs: false
overwriteVariablesWithLoop: true
closureUsesThis: true
matchingInheritedMethodNames: true
numericOperandsInArithmeticOperators: true
strictCalls: false
switchConditionsMatchingType: true
noVariableVariables: false
paths:
@@ -288,7 +290,7 @@ parameters:
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#1 \\$locale of static method Locale\\:\\:setDefault\\(\\) expects string, mixed given\\.$#"
message: "#^Parameter \\#1 \\$locale of static method Symfony\\\\Polyfill\\\\Intl\\\\Icu\\\\Locale\\:\\:setDefault\\(\\) expects string, mixed given\\.$#"
count: 1
path: src/Command/ExportCreateCommand.php
@@ -313,7 +315,7 @@ parameters:
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#2 \\$name of method Symfony\\\\Component\\\\HttpFoundation\\\\File\\\\File\\:\\:move\\(\\) expects string\\|null, list\\<string\\>\\|string given\\.$#"
message: "#^Parameter \\#2 \\$name of method Symfony\\\\Component\\\\HttpFoundation\\\\File\\\\File\\:\\:move\\(\\) expects string\\|null, array\\<int, string\\>\\|string given\\.$#"
count: 1
path: src/Command/ExportCreateCommand.php
@@ -327,6 +329,16 @@ parameters:
count: 4
path: src/Command/InstallCommand.php
-
message: "#^Cannot call method get\\(\\) on Symfony\\\\Component\\\\Console\\\\Helper\\\\HelperSet\\|null\\.$#"
count: 1
path: src/Command/InstallCommand.php
-
message: "#^Method App\\\\Command\\\\InstallCommand\\:\\:askConfirmation\\(\\) should return bool but returns mixed\\.$#"
count: 1
path: src/Command/InstallCommand.php
-
message: "#^Binary operation \"\\.\" between non\\-empty\\-string and non\\-empty\\-list\\<string\\>\\|string results in an error\\.$#"
count: 1
@@ -463,7 +475,7 @@ parameters:
path: src/Command/TranslationCommand.php
-
message: "#^Argument of an invalid type list\\<string\\>\\|false supplied for foreach, only iterables are supported\\.$#"
message: "#^Argument of an invalid type array\\<int, string\\>\\|false supplied for foreach, only iterables are supported\\.$#"
count: 4
path: src/Command/TranslationCommand.php
@@ -542,6 +554,11 @@ parameters:
count: 1
path: src/Command/TranslationCommand.php
-
message: "#^Cannot call method find\\(\\) on Symfony\\\\Component\\\\Console\\\\Application\\|null\\.$#"
count: 3
path: src/Command/UpdateCommand.php
-
message: "#^Method App\\\\Configuration\\\\LdapConfiguration\\:\\:getConnectionParameters\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -697,6 +714,11 @@ parameters:
count: 1
path: src/Controller/ActivityController.php
-
message: "#^Parameter \\#1 \\$returnTo of method OneLogin\\\\Saml2\\\\Auth\\:\\:login\\(\\) expects string\\|null, mixed given\\.$#"
count: 1
path: src/Controller/Auth/SamlController.php
-
message: "#^Cannot access offset 'user' on mixed\\.$#"
count: 1
@@ -1287,6 +1309,16 @@ parameters:
count: 1
path: src/Entity/Team.php
-
message: "#^Method App\\\\Entity\\\\Team\\:\\:getTeamleads\\(\\) should return array\\<App\\\\Entity\\\\User\\> but returns array\\<int, App\\\\Entity\\\\User\\|null\\>\\.$#"
count: 1
path: src/Entity/Team.php
-
message: "#^Method App\\\\Entity\\\\Team\\:\\:getUsers\\(\\) should return array\\<App\\\\Entity\\\\User\\> but returns array\\<int, App\\\\Entity\\\\User\\|null\\>\\.$#"
count: 1
path: src/Entity/Team.php
-
message: "#^Property App\\\\Entity\\\\Team\\:\\:\\$activities with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection does not specify its types\\: TKey, T$#"
count: 1
@@ -1448,7 +1480,7 @@ parameters:
path: src/Entity/UserPreference.php
-
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, string\\|null given\\.$#"
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, string\\|null given\\.$#"
count: 1
path: src/Entity/UserPreference.php
@@ -1462,6 +1494,11 @@ parameters:
count: 1
path: src/Entity/UserPreference.php
-
message: "#^Property App\\\\Entity\\\\UserPreference\\:\\:\\$value \\(string\\|null\\) does not accept mixed\\.$#"
count: 1
path: src/Entity/UserPreference.php
-
message: "#^Method App\\\\Event\\\\AbstractTimesheetMultipleEvent\\:\\:getTimesheets\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -1787,6 +1824,11 @@ parameters:
count: 1
path: src/Export/Spreadsheet/EntityWithMetaFieldsExporter.php
-
message: "#^Method App\\\\Export\\\\Spreadsheet\\\\Extractor\\\\AnnotationExtractor\\:\\:extract\\(\\) should return array\\<App\\\\Export\\\\Spreadsheet\\\\ColumnDefinition\\> but returns array\\<int, App\\\\Export\\\\Spreadsheet\\\\ColumnDefinition\\|null\\>\\.$#"
count: 1
path: src/Export/Spreadsheet/Extractor/AnnotationExtractor.php
-
message: "#^Parameter \\#1 \\$objectOrClass of class ReflectionClass constructor expects class\\-string\\<T of object\\>\\|T of object, string given\\.$#"
count: 1
@@ -1978,7 +2020,7 @@ parameters:
path: src/Form/Helper/ActivityHelper.php
-
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, bool\\|float\\|int\\|string given\\.$#"
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, bool\\|float\\|int\\|string given\\.$#"
count: 1
path: src/Form/Helper/ActivityHelper.php
@@ -1993,7 +2035,7 @@ parameters:
path: src/Form/Helper/CustomerHelper.php
-
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, bool\\|float\\|int\\|string given\\.$#"
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, bool\\|float\\|int\\|string given\\.$#"
count: 1
path: src/Form/Helper/CustomerHelper.php
@@ -2008,7 +2050,7 @@ parameters:
path: src/Form/Helper/ProjectHelper.php
-
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, bool\\|float\\|int\\|string given\\.$#"
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, bool\\|float\\|int\\|string given\\.$#"
count: 1
path: src/Form/Helper/ProjectHelper.php
@@ -3338,7 +3380,7 @@ parameters:
path: src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
-
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, mixed given\\.$#"
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, mixed given\\.$#"
count: 1
path: src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
@@ -3368,7 +3410,7 @@ parameters:
path: src/Invoice/Renderer/DocxRenderer.php
-
message: "#^Parameter \\#3 \\$subject of function preg_replace expects array\\<float\\|int\\|string\\>\\|string, mixed given\\.$#"
message: "#^Parameter \\#3 \\$subject of function preg_replace expects array\\|string, mixed given\\.$#"
count: 2
path: src/Invoice/Renderer/DocxRenderer.php
@@ -3532,6 +3574,11 @@ parameters:
count: 1
path: src/Ldap/LdapUserProvider.php
-
message: "#^Parameter \\#1 \\.\\.\\.\\$addresses of method Symfony\\\\Component\\\\Mime\\\\Email\\:\\:from\\(\\) expects string\\|Symfony\\\\Component\\\\Mime\\\\Address, string\\|null given\\.$#"
count: 1
path: src/Mail/KimaiMailer.php
-
message: "#^Parameter \\#1 \\.\\.\\.\\$arrays of function array_merge expects array, mixed given\\.$#"
count: 1
@@ -3547,6 +3594,11 @@ parameters:
count: 1
path: src/Model/MonthlyStatistic.php
-
message: "#^Method App\\\\Model\\\\MonthlyStatistic\\:\\:getYears\\(\\) should return array\\<string\\> but returns array\\<int, int\\|string\\>\\.$#"
count: 1
path: src/Model/MonthlyStatistic.php
-
message: "#^Method App\\\\Model\\\\Statistic\\\\Day\\:\\:getDetails\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -3617,6 +3669,11 @@ parameters:
count: 1
path: src/Plugin/PluginMetadata.php
-
message: "#^Parameter \\#2 \\$array of function array_key_exists expects array, mixed given\\.$#"
count: 1
path: src/Plugin/PluginMetadata.php
-
message: "#^Cannot access offset 'duration' on mixed\\.$#"
count: 3
@@ -3867,6 +3924,11 @@ parameters:
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Method App\\\\Repository\\\\TimesheetRepository\\:\\:getRawData\\(\\) should return array but returns mixed\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Parameter \\#1 \\$amountThisMonth of method App\\\\Model\\\\TimesheetStatistic\\:\\:setAmountThisMonth\\(\\) expects float\\|int, mixed given\\.$#"
count: 1
@@ -3962,6 +4024,11 @@ parameters:
count: 1
path: src/Saml/Security/SamlAuthenticationFailureHandler.php
-
message: "#^Method App\\\\Saml\\\\Security\\\\SamlAuthenticationSuccessHandler\\:\\:determineTargetUrl\\(\\) should return string but returns mixed\\.$#"
count: 1
path: src/Saml/Security/SamlAuthenticationSuccessHandler.php
-
message: "#^Property App\\\\Saml\\\\Security\\\\SamlAuthenticationSuccessHandler\\:\\:\\$defaultOptions has no type specified\\.$#"
count: 1
@@ -4403,7 +4470,7 @@ parameters:
path: src/Utils/LocaleFormatter.php
-
message: "#^Method App\\\\Utils\\\\LocaleFormatter\\:\\:durationDecimal\\(\\) should return string but returns string\\|false\\.$#"
message: "#^Method App\\\\Utils\\\\LocaleFormatter\\:\\:durationDecimal\\(\\) should return string but returns bool\\|string\\.$#"
count: 1
path: src/Utils/LocaleFormatter.php
@@ -4413,17 +4480,17 @@ parameters:
path: src/Utils/LocaleFormatter.php
-
message: "#^Method App\\\\Utils\\\\LocaleFormatter\\:\\:money\\(\\) should return string but returns string\\|false\\.$#"
count: 2
path: src/Utils/LocaleFormatter.php
-
message: "#^Parameter \\#1 \\$num of method NumberFormatter\\:\\:format\\(\\) expects float\\|int, float\\|int\\|string given\\.$#"
message: "#^Method App\\\\Utils\\\\LocaleFormatter\\:\\:money\\(\\) should return string but returns bool\\|string\\.$#"
count: 1
path: src/Utils/LocaleFormatter.php
-
message: "#^Parameter \\#2 \\$currency of method NumberFormatter\\:\\:formatCurrency\\(\\) expects string, string\\|null given\\.$#"
message: "#^Parameter \\#1 \\$num of method Symfony\\\\Polyfill\\\\Intl\\\\Icu\\\\NumberFormatter\\:\\:format\\(\\) expects float\\|int, float\\|int\\|string given\\.$#"
count: 1
path: src/Utils/LocaleFormatter.php
-
message: "#^Parameter \\#2 \\$currency of method Symfony\\\\Polyfill\\\\Intl\\\\Icu\\\\NumberFormatter\\:\\:formatCurrency\\(\\) expects string, string\\|null given\\.$#"
count: 1
path: src/Utils/LocaleFormatter.php

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,10 +3,10 @@
"app": {
"js": [
"/build/runtime.74179306.js",
"/build/app.dd395c1d.js"
"/build/app.6cdb49cb.js"
],
"css": [
"/build/app.6bd98062.css"
"/build/app.1ed18844.css"
]
},
"app-rtl": {
@@ -15,7 +15,7 @@
"/build/app-rtl.97153087.js"
],
"css": [
"/build/app-rtl.6a14a027.css"
"/build/app-rtl.d38ad12c.css"
]
},
"export-pdf": {
@@ -72,10 +72,10 @@
},
"integrity": {
"/build/runtime.74179306.js": "sha384-OC1hTNUXUalKJcvmzrZ0TMCOIwnhxCxgG9dQkbcwR7WcBBCkl2H8bs3giiT2pAwG",
"/build/app.dd395c1d.js": "sha384-OMqLzNbXe5Dx/gPoy67bNHWTehw5vz1UYQ0GsfRwpaokM+MwBRymwkC4MT28rkjz",
"/build/app.6bd98062.css": "sha384-BB9TrvQK/jlfhbdISEdBH5ZaZvbrPnZ+AcocXs1uQNh2yIQFboslUY9Up3iqnY4h",
"/build/app.6cdb49cb.js": "sha384-psXCV8SG68O80ZJHHeSbCJPQ8RoepaMeI1pEypTHu0Q8ySLrhlclhKHIL2dZ0ZQJ",
"/build/app.1ed18844.css": "sha384-fEjS/tjc8m7jrw28/eWZFOTO6bugR9ILfksKvBrF46wtmqlage1s/P4+ZeYuqgpq",
"/build/app-rtl.97153087.js": "sha384-jX7jRUAa8rH29Eg8jLIUKGfGcOT6RBz/P90plXmZPadf2CXKUBdcNGrspaejCHkr",
"/build/app-rtl.6a14a027.css": "sha384-ZmUAtO0Z/WfjYxJNleP3rBJpDmAnbaTlFMtejV/YpsshxkqmyNUneCIos9uMX7kH",
"/build/app-rtl.d38ad12c.css": "sha384-eFvMRueGVAxDxpr3s4y1EhHnVb8FQOr+KQgOuTzbW11ILydpLIvObiiuqNpf4mWO",
"/build/export-pdf.1442bee7.js": "sha384-C6agvjJnQUsMCxaZ/J7dUZU3cpC7uHKI9ZtnfGpRqYbjyw91YLy1oV3iNYgwYyCF",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.7ef8a0c8.js": "sha384-z4lZ1Ig3+NPigrRyGPZoff0gG3n5PnCjaDJ73ATnzdUYk0lOCEtNV9fg35VRD0vG",

View File

@@ -1,7 +1,7 @@
{
"build/app.css": "/build/app.6bd98062.css",
"build/app.js": "/build/app.dd395c1d.js",
"build/app-rtl.css": "/build/app-rtl.6a14a027.css",
"build/app.css": "/build/app.1ed18844.css",
"build/app.js": "/build/app.6cdb49cb.js",
"build/app-rtl.css": "/build/app-rtl.d38ad12c.css",
"build/app-rtl.js": "/build/app-rtl.97153087.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.1442bee7.js",

View File

@@ -4,6 +4,9 @@ use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
// TODO remove once PARTIAL usage was replaced entirely
\Doctrine\Deprecations\Deprecation::ignoreDeprecations('https://github.com/doctrine/orm/issues/8471');
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

View File

@@ -17,7 +17,7 @@ use App\Entity\Timesheet;
use App\Event\PageActionsEvent;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Attribute\Model;
use Nelmio\ApiDocBundle\Annotation\Model;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

View File

@@ -9,7 +9,6 @@
namespace App\API;
use App\Activity\ActivityService;
use App\Entity\Activity;
use App\Entity\ActivityRate;
use App\Entity\User;
@@ -47,8 +46,7 @@ final class ActivityController extends BaseApiController
private readonly ViewHandlerInterface $viewHandler,
private readonly ActivityRepository $repository,
private readonly EventDispatcherInterface $dispatcher,
private readonly ActivityRateRepository $activityRateRepository,
private readonly ActivityService $activityService
private readonly ActivityRateRepository $activityRateRepository
) {
}
@@ -210,25 +208,6 @@ final class ActivityController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Delete an existing activity
*
* [DANGER] This will also delete ALL linked timesheets.
* Maybe use `PATCH` instead and mark it as inactive with `visible=false`?
*/
#[IsGranted('delete', 'activity')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one activity')])]
#[OA\Parameter(name: 'id', description: 'Activity ID to delete', in: 'path', required: true)]
#[Route(path: '/{id}', name: 'delete_activity', requirements: ['id' => '\d+'], methods: ['DELETE'])]
public function deleteAction(Activity $activity): Response
{
$this->activityService->deleteActivity($activity);
$view = new View(null, Response::HTTP_NO_CONTENT);
return $this->viewHandler->handle($view);
}
/**
* Sets the value of a meta-field for an existing activity
*/

View File

@@ -13,7 +13,7 @@ use App\API\Model\TimesheetConfig;
use App\Configuration\SystemConfiguration;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Attribute\Model;
use Nelmio\ApiDocBundle\Annotation\Model;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

View File

@@ -46,8 +46,7 @@ final class CustomerController extends BaseApiController
private readonly ViewHandlerInterface $viewHandler,
private readonly CustomerRepository $repository,
private readonly EventDispatcherInterface $dispatcher,
private readonly CustomerRateRepository $customerRateRepository,
private readonly CustomerService $customerService,
private readonly CustomerRateRepository $customerRateRepository
) {
}
@@ -186,25 +185,6 @@ final class CustomerController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Delete an existing customer
*
* [DANGER] This will also delete ALL linked projects, project activities and timesheets.
* Maybe use `PATCH` instead and mark it as inactive with `visible=false`?
*/
#[IsGranted('delete', 'customer')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one customer')])]
#[OA\Parameter(name: 'id', description: 'Customer ID to delete', in: 'path', required: true)]
#[Route(path: '/{id}', name: 'delete_customer', requirements: ['id' => '\d+'], methods: ['DELETE'])]
public function deleteAction(Customer $customer): Response
{
$this->customerService->deleteCustomer($customer);
$view = new View(null, Response::HTTP_NO_CONTENT);
return $this->viewHandler->handle($view);
}
/**
* Sets the value of a meta-field for an existing customer
*/

View File

@@ -240,25 +240,6 @@ final class ProjectController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* Delete an existing project
*
* [DANGER] This will also delete ALL linked activities and timesheets.
* Maybe use `PATCH` instead and mark it as inactive with `visible=false`?
*/
#[IsGranted('delete', 'project')]
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one project')])]
#[OA\Parameter(name: 'id', description: 'Project ID to delete', in: 'path', required: true)]
#[Route(path: '/{id}', name: 'delete_project', requirements: ['id' => '\d+'], methods: ['DELETE'])]
public function deleteAction(Project $project): Response
{
$this->projectService->deleteProject($project);
$view = new View(null, Response::HTTP_NO_CONTENT);
return $this->viewHandler->handle($view);
}
/**
* Sets the value of a meta-field for an existing project
*/

View File

@@ -14,7 +14,7 @@ use App\API\Model\Version;
use App\Plugin\PluginManager;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Attribute\Model;
use Nelmio\ApiDocBundle\Annotation\Model;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

View File

@@ -37,7 +37,6 @@ use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints;
@@ -182,11 +181,8 @@ final class TimesheetController extends BaseApiController
/** @var array<string> $tags */
$tags = $paramFetcher->get('tags');
if (\is_array($tags) && \count($tags) > 0) {
$tagsByName = $this->tagRepository->findTagsByName($tags, true);
if (\count($tagsByName) === 0) {
throw new BadRequestHttpException('Given tags were not found');
}
foreach ($tagsByName as $tag) {
$tags = $this->tagRepository->findTagsByName($tags, true);
foreach ($tags as $tag) {
$query->addTag($tag);
}
}

View File

@@ -15,7 +15,6 @@ use App\Entity\Project;
use App\Event\ActivityCreateEvent;
use App\Event\ActivityCreatePostEvent;
use App\Event\ActivityCreatePreEvent;
use App\Event\ActivityDeleteEvent;
use App\Event\ActivityMetaDefinitionEvent;
use App\Event\ActivityUpdatePostEvent;
use App\Event\ActivityUpdatePreEvent;
@@ -70,13 +69,8 @@ class ActivityService
return $activity;
}
public function deleteActivity(Activity $activity): void
{
$this->dispatcher->dispatch(new ActivityDeleteEvent($activity));
$this->repository->deleteActivity($activity);
}
/**
* @param Activity $activity
* @param string[] $groups
* @throws ValidationFailedException
*/

View File

@@ -10,24 +10,34 @@
namespace App\Activity;
use App\Entity\Activity;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Event\ActivityBudgetStatisticEvent;
use App\Event\ActivityStatisticEvent;
use App\Model\ActivityBudgetStatisticModel;
use App\Model\ActivityStatistic;
use App\Reporting\ActivityView\ActivityViewModel;
use App\Reporting\ActivityView\ActivityViewQuery;
use App\Repository\ActivityRepository;
use App\Repository\Loader\ActivityLoader;
use App\Repository\TimesheetRepository;
use App\Timesheet\DateTimeFactory;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
/**
* @final
*/
class ActivityStatisticService
{
public function __construct(private readonly TimesheetRepository $timesheetRepository, private readonly EventDispatcherInterface $dispatcher)
public function __construct(
private readonly TimesheetRepository $timesheetRepository,
private readonly ActivityRepository $activityRepository,
private readonly EventDispatcherInterface $dispatcher
)
{
}
@@ -197,4 +207,201 @@ class ActivityStatisticService
return $qb;
}
/**
* @return Activity[]
*/
public function findActivitiesForView(ActivityViewQuery $query): array
{
$user = $query->getUser();
$today = clone $query->getToday();
$qb = $this->activityRepository->createQueryBuilder('a');
$qb
->select('a')
->leftJoin('a.project', 'p')
->leftJoin('p.customer', 'c')
->andWhere($qb->expr()->eq('a.visible', true))
->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('a.project'),
$qb->expr()->andX(
$qb->expr()->eq('p.visible', true),
$qb->expr()->eq('c.visible', true),
$qb->expr()->orX(
$qb->expr()->isNull('p.end'),
$qb->expr()->gte('p.end', ':project_end')
)
)
)
)
->addGroupBy('a')
->setParameter('project_end', $today, Types::DATETIME_MUTABLE)
;
if ($query->getProject() !== null) {
$qb->andWhere($qb->expr()->eq('p', ':project'));
$qb->setParameter('project', $query->getProject()->getId());
}
if (!$query->isIncludeNoWork()) {
$qb
->leftJoin(Timesheet::class, 't', 'WITH', 'a.id = t.activity')
->andHaving($qb->expr()->gt('SUM(t.duration)', 0))
;
}
if ($query->isIncludeWithBudget()) {
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->gt('a.timeBudget', 0),
$qb->expr()->gt('a.budget', 0)
)
);
} elseif ($query->isIncludeWithoutBudget()) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->eq('a.timeBudget', 0),
$qb->expr()->eq('a.budget', 0)
)
);
}
$this->activityRepository->addPermissionCriteria($qb, $user);
/** @var Activity[] $activities */
$activities = $qb->getQuery()->getResult();
// pre-cache project objects instead of joining them
$loader = new ActivityLoader($this->activityRepository->createQueryBuilder('a')->getEntityManager());
$loader->loadResults($activities);
return $activities;
}
/**
* @param Activity[] $activities
* @return ActivityViewModel[]
*/
public function getActivityView(User $user, array $activities, DateTimeInterface $today): array
{
$factory = DateTimeFactory::createByUser($user);
$today = clone $today;
$startOfWeek = $factory->getStartOfWeek($today);
$endOfWeek = $factory->getEndOfWeek($today);
$startMonth = (clone $startOfWeek)->modify('first day of this month');
$endMonth = (clone $startOfWeek)->modify('last day of this month');
$activityView = [];
foreach ($activities as $activity) {
$activityView[$activity->getId()] = new ActivityViewModel($activity);
}
$budgetStats = $this->getBudgetStatisticModelForActivities($activities, $today);
foreach ($budgetStats as $model) {
$activityView[$model->getActivity()->getId()]->setBudgetStatisticModel($model);
}
$activityIds = array_keys($activityView);
$tplQb = $this->timesheetRepository->createQueryBuilder('t');
$tplQb
->select('IDENTITY(t.activity) AS 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('t.activity', ':activity'))
->groupBy('t.activity')
->setParameter('activity', $activityIds)
;
$qb = clone $tplQb;
$qb->addSelect('MAX(t.date) as lastRecord');
/** @var array<int, array{id: int, amount: int, duration: int, rate: float, lastRecord: string}> $result */
$result = $qb->getQuery()->getScalarResult();
foreach ($result as $row) {
$activityView[$row['id']]->setDurationTotal($row['duration']);
$activityView[$row['id']]->setRateTotal($row['rate']);
$activityView[$row['id']]->setTimesheetCounter($row['amount']);
if ($row['lastRecord'] !== null) {
// might be the wrong timezone
$activityView[$row['id']]->setLastRecord($factory->createDateTime($row['lastRecord']));
}
}
// values for today
$qb = clone $tplQb;
$qb
->andWhere('DATE(t.date) = :start_date')
->setParameter('start_date', $today, Types::DATETIME_MUTABLE)
;
/** @var array<int, array{id: int, amount: int, duration: int, rate: float}> $result */
$result = $qb->getQuery()->getScalarResult();
foreach ($result as $row) {
$activityView[$row['id']]->setDurationDay($row['duration'] ?? 0);
}
// values for the current week
$qb = clone $tplQb;
$qb
->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date')
->setParameter('start_date', $startOfWeek, Types::DATETIME_MUTABLE)
->setParameter('end_date', $endOfWeek, Types::DATETIME_MUTABLE)
;
/** @var array<int, array{id: int, amount: int, duration: int, rate: float}> $result */
$result = $qb->getQuery()->getScalarResult();
foreach ($result as $row) {
$activityView[$row['id']]->setDurationWeek($row['duration']);
}
// values for the current month
$qb = clone $tplQb;
$qb
->andWhere('DATE(t.date) BETWEEN :start_date AND :end_date')
->setParameter('start_date', $startMonth, Types::DATETIME_MUTABLE)
->setParameter('end_date', $endMonth, Types::DATETIME_MUTABLE)
;
/** @var array<int, array{id: int, amount: int, duration: int, rate: float}> $result */
$result = $qb->getQuery()->getScalarResult();
foreach ($result as $row) {
$activityView[$row['id']]->setDurationMonth($row['duration']);
}
$qb = clone $tplQb;
$qb
->addSelect('t.exported')
->addSelect('t.billable')
->addGroupBy('t.exported')
->addGroupBy('t.billable')
;
/** @var array<int, array{id: int, amount: int, duration: int, rate: float, exported: bool, billable: bool}> $result */
$result = $qb->getQuery()->getScalarResult();
foreach ($result as $row) {
/** @var ActivityViewModel $view */
$view = $activityView[$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($activityView);
}
}

View File

@@ -11,13 +11,14 @@ namespace App\Command;
use App\Constants;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
@@ -25,10 +26,10 @@ use Symfony\Component\Console\Style\SymfonyStyle;
*
* @codeCoverageIgnore
*/
#[AsCommand(name: 'kimai:install', description: 'Kimai installation command', aliases: ['kimai:update'])]
#[AsCommand(name: 'kimai:install')]
final class InstallCommand extends Command
{
public function __construct(private readonly Connection $connection)
public function __construct(private Connection $connection, private string $kernelEnvironment)
{
parent::__construct();
}
@@ -36,7 +37,8 @@ final class InstallCommand extends Command
protected function configure(): void
{
$this
->setHelp('This command will perform the installation steps to bootstrap the application, database and plugins.')
->setDescription('Basic installation for Kimai')
->setHelp('This command will perform the basic installation steps to get Kimai up and running.')
->addOption('no-cache', null, InputOption::VALUE_NONE, 'Skip cache re-generation')
;
}
@@ -45,23 +47,19 @@ final class InstallCommand extends Command
{
$io = new SymfonyStyle($input, $output);
$io->text('Start installation ...');
/** @var Application $application */
$application = $this->getApplication();
$environment = $application->getKernel()->getEnvironment();
$io->title('Kimai installation running ...');
// create the database, in case it is not yet existing
try {
// creates the database if it is not yet existing
$this->createDatabase($io, $output);
$this->createDatabase($io, $input, $output);
} catch (\Exception $ex) {
$io->error('Failed to create database: ' . $ex->getMessage());
return Command::FAILURE;
}
// bootstrap database ONLY via doctrine migrations, so all installation will have the correct and same state
try {
// bootstrap database ONLY via doctrine migrations, so all installation will have the same state
$this->importMigrations($io, $output);
} catch (\Exception $ex) {
$io->error('Failed to set migration status: ' . $ex->getMessage());
@@ -70,22 +68,12 @@ final class InstallCommand extends Command
}
if (!$input->getOption('no-cache')) {
// show manual steps in case this fails
$cacheResult = $this->rebuildCaches($environment, $io, $input, $output);
if ($cacheResult !== Command::SUCCESS) {
$io->warning(
[
'Please run the cache commands manually:',
'bin/console cache:clear --env=' . $environment . PHP_EOL .
'bin/console cache:warmup --env=' . $environment
]
);
}
// flush the cache, just to make sure ... and ignore result
$this->rebuildCaches($this->kernelEnvironment, $io, $input, $output);
}
$io->success(
\sprintf('Successfully installed %s version %s 🎉', Constants::SOFTWARE, Constants::VERSION)
\sprintf('Congratulations! Successfully installed %s version %s', Constants::SOFTWARE, Constants::VERSION)
);
return Command::SUCCESS;
@@ -93,13 +81,11 @@ final class InstallCommand extends Command
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
{
$io->text('Rebuilding cache ...');
$io->text('Rebuilding your cache, please be patient ...');
$command = $this->getApplication()->find('cache:clear');
try {
if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) {
throw new \RuntimeException('Invalid file permissions?');
}
$command->run(new ArrayInput(['--env' => $environment]), $output);
} catch (\Exception $ex) {
$io->error('Failed to clear cache: ' . $ex->getMessage());
@@ -108,9 +94,7 @@ final class InstallCommand extends Command
$command = $this->getApplication()->find('cache:warmup');
try {
if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) {
throw new \RuntimeException('Invalid file permissions?');
}
$command->run(new ArrayInput(['--env' => $environment]), $output);
} catch (\Exception $ex) {
$io->error('Failed to warmup cache: ' . $ex->getMessage());
@@ -122,36 +106,55 @@ final class InstallCommand extends Command
private function importMigrations(SymfonyStyle $io, OutputInterface $output): void
{
$io->text('Creating database ...');
$command = $this->getApplication()->find('doctrine:migrations:migrate');
$cmdInput = new ArrayInput(['--allow-no-migration' => true]);
$cmdInput->setInteractive(false);
$result = $command->run($cmdInput, $output);
$command->run($cmdInput, $output);
if (0 !== $result) {
throw new \Exception('Failed updating database.');
}
$io->writeln('');
}
private function createDatabase(SymfonyStyle $io, OutputInterface $output): void
private function createDatabase(SymfonyStyle $io, InputInterface $input, OutputInterface $output): void
{
try {
if ($this->connection->isConnected()) {
// database exists: we can skip this step
$io->note(\sprintf('Database is existing and connection could be established'));
return;
}
} catch (\Exception $ex) {
// this means the database does not exist and the connection failed
if (!$this->askConfirmation($input, $output, \sprintf('Create the database "%s" (yes) or skip (no)?', $this->connection->getDatabase()), true)) {
throw new \Exception('Skipped database creation, aborting installation');
}
} catch (\Exception $exception) {
// this likely means that the database does not exist. the latest doctrine release
// changed the behavior: in previous version this code did not throw an exception.
}
$options = ['--if-not-exists' => true];
$command = $this->getApplication()->find('doctrine:database:create');
$cmdInput = new ArrayInput(['--if-not-exists' => true]);
$cmdInput->setInteractive(false);
$result = $command->run($cmdInput, $output);
$result = $command->run(new ArrayInput($options), $output);
if (0 !== $result) {
throw new \Exception('Failed creating database: check your DATABASE_URL.');
throw new \Exception('Failed creating database. Check your credentials in DATABASE_URL');
}
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @param string $question
* @param bool $default
* @return bool
*/
private function askConfirmation(InputInterface $input, OutputInterface $output, $question, $default = false): bool
{
/** @var QuestionHelper $questionHelper */
$questionHelper = $this->getHelperSet()->get('question');
$text = \sprintf('<info>%s (yes/no)</info> [<comment>%s</comment>]:', $question, $default ? 'yes' : 'no');
$question = new ConfirmationQuestion(' ' . $text . ' ', $default, '/^y|yes/i');
return $questionHelper->ask($input, $output, $question);
}
}

View File

@@ -9,144 +9,37 @@
namespace App\Command;
use App\Plugin\Package;
use App\Plugin\PackageManager;
use App\Plugin\Plugin;
use App\Plugin\PluginManager;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
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\Process\PhpSubprocess;
#[AsCommand(name: 'kimai:plugins', description: 'Manage Kimai plugins')]
/**
* Command used to fetch plugin information.
*/
#[AsCommand(name: 'kimai:plugins')]
final class PluginCommand extends Command
{
public function __construct(
private readonly PluginManager $pluginManager,
private readonly PackageManager $packageManager
)
public function __construct(private PluginManager $plugins)
{
parent::__construct();
}
protected function configure(): void
{
$this->setHelp('Shows information about already installed plugins by default.');
$this->addOption('available', null, InputOption::VALUE_NONE, 'Show list of available plugins in ' . PackageManager::PACKAGE_DIR);
$this->addOption('composer', null, InputOption::VALUE_NONE, 'Dump list of available composer packages in ' . PackageManager::PACKAGE_DIR);
$this->addOption('install', null, InputOption::VALUE_NONE, 'Run plugins installer, previously installed via ./kimai.sh');
$this
->setDescription('Receive plugin information')
->setHelp('This command prints detailed plugin information.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if ($input->getOption('available')) {
return $this->listPackages($io, $this->packageManager->getAvailablePackages());
} elseif ($input->getOption('composer')) {
return $this->listComposerPackages($io, $this->packageManager->getAvailablePackages());
} elseif ($input->getOption('install')) {
return $this->installPlugins($io, $output, $this->pluginManager->getPlugins());
}
return $this->listInstalledPlugins($io, $this->pluginManager->getPlugins());
}
/**
* @param Plugin[] $plugins
*/
private function installPlugins(SymfonyStyle $io, OutputInterface $output, array $plugins): int
{
foreach ($plugins as $plugin) {
$config = $plugin->getPath() . '/migrations/doctrine_migrations.yaml';
if (!file_exists($config)) {
$config = $plugin->getPath() . '/Migrations/doctrine_migrations.yaml';
if (!file_exists($config)) {
continue;
}
}
// using getApplication()->find('doctrine:migrations:migrate') does NOT work here
// because the Doctrine command can only be executed once
// if run more than once it fails with a "Container is frozen" exception
$process = new PhpSubprocess([
'bin/console',
'doctrine:migrations:migrate',
'--allow-no-migration',
'--no-interaction',
'--configuration=' . $config
]);
$process->run();
if (!$process->isSuccessful()) {
$io->error('Failed to install bundle database: ' . PHP_EOL . $config);
$io->error($process->getErrorOutput());
} else {
if ($io->isVerbose()) {
$io->write($process->getOutput());
} else {
$io->success('Successfully installed: ' . $plugin->getName());
}
}
}
return Command::SUCCESS;
}
/**
* @param Package[] $packages
*/
private function listComposerPackages(SymfonyStyle $io, array $packages): int
{
if (empty($packages)) {
return Command::SUCCESS;
}
$all = [];
foreach ($packages as $package) {
$all[] = $package->getMetadata()->getPackage();
}
$io->write(implode(' ', $all));
return Command::SUCCESS;
}
/**
* @param Package[] $packages
*/
private function listPackages(SymfonyStyle $io, array $packages): int
{
if (empty($packages)) {
$io->warning('No packages to install found');
return Command::SUCCESS;
}
$rows = [];
foreach ($packages as $package) {
$metadata = $package->getMetadata();
$rows[] = [
$metadata->getName(),
$metadata->getVersion(),
$metadata->getKimaiVersion(),
$metadata->getPackage(),
$package->getPackageFile()->getPathname(),
];
}
$io->table(['Name', 'Version', 'Requires', 'Package', 'Directory'], $rows);
return Command::SUCCESS;
}
/**
* @param array<Plugin> $plugins
*/
private function listInstalledPlugins(SymfonyStyle $io, array $plugins): int
{
$plugins = $this->plugins->getPlugins();
if (empty($plugins)) {
$io->warning('No plugins installed');

View File

@@ -26,7 +26,7 @@ use Symfony\Component\Intl\Locales;
*
* @codeCoverageIgnore
*/
#[AsCommand(name: 'kimai:reset:locales', description: 'Regenerate the locale definition file')]
#[AsCommand(name: 'kimai:reset:locales')]
final class RegenerateLocalesCommand extends Command
{
/**
@@ -66,6 +66,11 @@ final class RegenerateLocalesCommand extends Command
return $this->kernelEnvironment !== 'prod';
}
protected function configure(): void
{
$this->setDescription('Regenerate the locale definition file');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
@@ -131,15 +136,7 @@ final class RegenerateLocalesCommand extends Command
$shortTime = new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::SHORT);
$settings['date'] = $shortDate->getPattern();
if ($settings['date'] === false) {
$io->error('Invalid date pattern for locale: ' . $locale);
continue;
}
$settings['time'] = $shortTime->getPattern();
if ($settings['time'] === false) {
$io->error('Invalid time pattern for locale: ' . $locale);
continue;
}
// see https://github.com/kimai/kimai/issues/4402 - Korean time format failed parsing
// special case when time pattern starts with A / a => this will lead to an error

View File

@@ -48,9 +48,7 @@ final class TranslationCommand extends Command
->addOption('fill-empty', null, InputOption::VALUE_NONE, 'Pre-fills empty translations with the english version')
->addOption('delete-empty', null, InputOption::VALUE_NONE, 'Delete all empty keys and files which have no translated key at all')
->addOption('move-resname', null, InputOption::VALUE_REQUIRED, 'Move a resname from one file to another (needs "source" and "target" options)')
->addOption('move-all', null, InputOption::VALUE_NONE, 'Move all keys from one file to another (needs "source" and "target" options)')
->addOption('source', null, InputOption::VALUE_REQUIRED, 'Single source file to use')
->addOption('only-core', null, InputOption::VALUE_NONE, 'Do not include plugin and theme directories')
->addOption('target', null, InputOption::VALUE_REQUIRED, 'Single target file to use')
// DEEPL TRANSLATION FEATURE - UNTESTED
->addOption('translate-locale', null, InputOption::VALUE_REQUIRED, 'Translate into the given locale with Deepl')
@@ -70,13 +68,10 @@ final class TranslationCommand extends Command
$bases = [
'core' => $this->projectDirectory . '/translations/*.xlf',
'plugins' => $this->projectDirectory . Kernel::PLUGIN_DIRECTORY . '/*/Resources/translations/*.xlf',
'theme' => $this->projectDirectory . '/vendor/kevinpapst/tabler-bundle/translations/*.xlf',
];
if (!$input->getOption('only-core')) {
$bases['plugins'] = $this->projectDirectory . Kernel::PLUGIN_DIRECTORY . '/*/Resources/translations/*.xlf';
$bases['theme'] = $this->projectDirectory . '/vendor/kevinpapst/tabler-bundle/translations/*.xlf';
}
$sources = [];
if ($input->getOption('source') !== null) {
/** @var string $tmp */
@@ -151,19 +146,6 @@ final class TranslationCommand extends Command
return $this->moveResname($io, $moveResname, $sources, $targets);
}
// ==========================================================================
// Move all keys from source to target
// ==========================================================================
if ($input->getOption('move-all')) {
if (\count($sources) === 0 || \count($targets) === 0) {
$io->error('Moving all keys only works with one source and one target file');
return Command::FAILURE;
}
return $this->moveAll($io, $sources, $targets);
}
// ==========================================================================
// Fill empty translations with english version
// ==========================================================================
@@ -652,66 +634,4 @@ final class TranslationCommand extends Command
return Command::SUCCESS;
}
/**
* @param array<string> $sources
* @param array<string> $targets
*/
private function moveAll(SymfonyStyle $io, array $sources, array $targets): int
{
foreach ($sources as $source) {
$tmp = basename($source);
$pos = strpos($tmp, '.');
if ($pos === false) {
$io->error('Unexpected filename: ' . $source);
return Command::FAILURE;
}
$suffix = substr($tmp, $pos);
$target = null;
foreach ($targets as $t) {
if (str_ends_with($t, $suffix)) {
$target = $t;
}
}
if ($target === null) {
$io->error('Cannot find translation target file for source: ' . $source);
return Command::FAILURE;
}
$sourceDocument = new \DOMDocument('1.0');
$sourceDocument->load($source);
$targetDocument = new \DOMDocument('1.0');
$targetDocument->load($target);
$removeNodes = [];
/** @var \DOMElement $element */
foreach ($sourceDocument->getElementsByTagName('trans-unit') as $element) {
$newNode = $targetDocument->importNode($element, true);
$targetDocument->documentElement->firstElementChild->firstElementChild->appendChild($newNode); // @phpstan-ignore-line
$removeNodes[] = $element;
}
foreach ($removeNodes as $node) {
$sourceDocument->documentElement->firstElementChild->firstElementChild->removeChild($node); // @phpstan-ignore-line
}
$xmlDocument = new \DOMDocument('1.0');
$xmlDocument->preserveWhiteSpace = false;
$xmlDocument->formatOutput = true;
$xmlDocument->loadXML($sourceDocument->saveXML()); // @phpstan-ignore-line
file_put_contents($source, $xmlDocument->saveXML());
$xmlDocument = new \DOMDocument('1.0');
$xmlDocument->preserveWhiteSpace = false;
$xmlDocument->formatOutput = true;
$xmlDocument->loadXML($targetDocument->saveXML()); // @phpstan-ignore-line
file_put_contents($target, $xmlDocument->saveXML());
}
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,137 @@
<?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\Constants;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\ConnectionException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Command used to update a Kimai installation.
*/
#[AsCommand(name: 'kimai:update')]
final class UpdateCommand extends Command
{
public function __construct(private Connection $connection, private string $kernelEnvironment)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setDescription('Update your Kimai installation')
->setHelp('This command will execute all required steps to update your Kimai installation.')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Kimai updates running ...');
$environment = $this->kernelEnvironment;
// make sure database is available, Kimai running and installed
try {
if (!$this->connection->createSchemaManager()->tablesExist(['kimai2_users', 'kimai2_timesheet'])) {
$io->error('Tables missing. Did you run the installer already?');
return Command::FAILURE;
}
if (!$this->connection->createSchemaManager()->tablesExist(['migration_versions'])) {
$io->error('Unknown migration status, aborting database update');
return Command::FAILURE;
}
} catch (ConnectionException $e) {
$io->error(['Database connection could not be established.', $e->getMessage()]);
return Command::FAILURE;
} catch (\Exception $ex) {
$io->error(['Failed to validate database.', $ex->getMessage()]);
return Command::FAILURE;
}
// execute latest doctrine migrations
try {
$command = $this->getApplication()->find('doctrine:migrations:migrate');
$cmdInput = new ArrayInput(['--allow-no-migration' => true]);
$cmdInput->setInteractive(false);
if (0 !== $command->run($cmdInput, $output)) {
throw new \RuntimeException('CRITICAL: problem when migrating database');
}
$io->writeln('');
} catch (\Exception $ex) {
$io->error($ex->getMessage());
return Command::FAILURE;
}
// flush the cache, in case values from the database are cached
$cacheResult = $this->rebuildCaches($environment, $io, $input, $output);
if ($cacheResult !== Command::SUCCESS) {
$io->warning(
[
\sprintf('Updated %s to version %s but the cache could not be rebuilt.', Constants::SOFTWARE, Constants::VERSION),
'Please run the cache commands manually:',
'bin/console cache:clear --env=' . $environment . PHP_EOL .
'bin/console cache:warmup --env=' . $environment
]
);
} else {
$io->success(
\sprintf('Congratulations! Successfully updated %s to version %s', Constants::SOFTWARE, Constants::VERSION)
);
}
return Command::SUCCESS;
}
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
{
$io->text('Rebuilding your cache, please be patient ...');
$command = $this->getApplication()->find('cache:clear');
try {
if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) {
throw new \RuntimeException('Could not clear cache, missing permissions?');
}
} catch (\Exception $ex) {
$io->error($ex->getMessage());
return Command::FAILURE;
}
$command = $this->getApplication()->find('cache:warmup');
try {
if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) {
throw new \RuntimeException('Could not warmup cache, missing permissions?');
}
} catch (\Exception $ex) {
$io->error($ex->getMessage());
return Command::FAILURE;
}
return Command::SUCCESS;
}
}

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.26.0';
public const VERSION = '2.24.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 22600;
public const VERSION_ID = 22400;
/**
* The software name
*/

View File

@@ -12,24 +12,18 @@ namespace App\Controller\Auth;
use App\Configuration\SamlConfigurationInterface;
use App\Saml\SamlAuthFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
#[Route(path: '/saml')]
final class SamlController extends AbstractController
{
use TargetPathTrait;
public function __construct(
private readonly SamlAuthFactory $authFactory,
private readonly SamlConfigurationInterface $samlConfiguration,
private readonly Security $security,
private readonly SamlConfigurationInterface $samlConfiguration
)
{
}
@@ -60,12 +54,8 @@ final class SamlController extends AbstractController
throw new \RuntimeException($error);
}
$firewallName = $this->security->getFirewallConfig($request)?->getName();
if ($firewallName === null || $firewallName === '') {
throw new ServiceUnavailableHttpException(message: 'Unknown firewall.');
}
$redirectTarget = $this->getTargetPath($session, $firewallName);
// this does set headers and exit as $stay is not set to true
$redirectTarget = $session->get('_security.main.target_path');
if ($redirectTarget === null || $redirectTarget === '') {
$redirectTarget = $this->generateUrl('homepage', [], UrlGeneratorInterface::ABSOLUTE_URL);
}

View File

@@ -253,6 +253,7 @@ final class DoctorController extends AbstractController
'max_execution_time',
'date.timezone',
'allow_url_fopen',
'allow_url_include',
'default_charset',
'default_mimetype',
'display_errors',

View File

@@ -0,0 +1,57 @@
<?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\Activity\ActivityStatisticService;
use App\Controller\AbstractController;
use App\Reporting\ActivityView\ActivityViewForm;
use App\Reporting\ActivityView\ActivityViewQuery;
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 ActivityViewController extends AbstractController
{
#[Route(path: '/activity/project_view', name: 'report_activity_view', methods: ['GET', 'POST'])]
#[IsGranted('report:project')]
#[IsGranted(new Expression("is_granted('budget_any', 'activity')"))]
public function __invoke(Request $request, ActivityStatisticService $service): Response
{
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();
$query = new ActivityViewQuery($dateFactory->createDateTime(), $user);
$form = $this->createFormForGetRequest(ActivityViewForm::class, $query);
$form->submit($request->query->all(), false);
$activities = $service->findActivitiesForView($query);
$entries = $service->getActivityView($user, $activities, $query->getToday());
$byCustomer = [];
foreach ($entries as $entry) {
$project = $entry->getActivity()->getProject();
$key = ($project === null || $project->getId() === null ? '__EMPTY__' : $project->getId());
if (!\array_key_exists($key, $byCustomer)) {
$byCustomer[$key] = ['project' => $project, 'activities' => [], 'name' => $project !== null ? $project->getName() : ''];
}
$byCustomer[$key]['activities'][] = $entry;
}
return $this->render('reporting/activity_view.html.twig', [
'entries' => $byCustomer,
'form' => $form->createView(),
'report_title' => 'report_activity_view',
'tableName' => 'activity_view_reporting',
'now' => $dateFactory->createDateTime(),
]);
}
}

View File

@@ -19,7 +19,6 @@ use App\Form\Model\MultiUserTimesheet;
use App\Form\TimesheetAdminEditForm;
use App\Form\TimesheetMultiUserEditForm;
use App\Repository\Query\TimesheetQuery;
use App\Repository\Query\TimesheetQueryHint;
use App\Utils\PageSetup;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\Form\FormInterface;
@@ -112,7 +111,6 @@ final class TimesheetTeamController extends TimesheetAbstractController
$tags[] = $tag;
}
$newTimesheets = [];
foreach ($allUsers as $user) {
$newTimesheet = $entry->createCopy();
$newTimesheet->setUser($user);
@@ -120,11 +118,6 @@ final class TimesheetTeamController extends TimesheetAbstractController
$newTimesheet->addTag($tag);
}
$this->service->prepareNewTimesheet($newTimesheet, $request);
$this->service->validateTimesheet($newTimesheet);
$newTimesheets[] = $newTimesheet;
}
foreach ($newTimesheets as $newTimesheet) {
$this->service->saveNewTimesheet($newTimesheet);
}
@@ -181,7 +174,6 @@ final class TimesheetTeamController extends TimesheetAbstractController
protected function prepareQuery(TimesheetQuery $query): void
{
$query->setCurrentUser($this->getUser());
$query->addQueryHint(TimesheetQueryHint::USER_PREFERENCES); // e.g. for latest approval
}
protected function getCreateForm(Timesheet $entry): FormInterface

View File

@@ -14,7 +14,6 @@ use App\Entity\Customer;
use App\Event\CustomerCreateEvent;
use App\Event\CustomerCreatePostEvent;
use App\Event\CustomerCreatePreEvent;
use App\Event\CustomerDeleteEvent;
use App\Event\CustomerMetaDefinitionEvent;
use App\Event\CustomerUpdatePostEvent;
use App\Event\CustomerUpdatePreEvent;
@@ -74,12 +73,6 @@ final class CustomerService
return $customer;
}
public function deleteCustomer(Customer $customer): void
{
$this->dispatcher->dispatch(new CustomerDeleteEvent($customer));
$this->repository->deleteCustomer($customer);
}
/**
* @param string[] $groups
* @throws ValidationFailedException

View File

@@ -331,9 +331,6 @@ final class Configuration implements ConfigurationInterface
->booleanNode('require_activity')
->defaultTrue()
->end()
->booleanNode('break_time_active')
->defaultFalse()
->end()
->end()
->end()
->end()

View File

@@ -24,7 +24,7 @@ final class UTCDateTimeImmutableType extends DateTimeImmutableType
/**
* @param T $value
* @return (T is null ? null : string)
* @template T
* @template T<\DateTimeImmutable>
* @throws ConversionException
*/
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string

View File

@@ -25,7 +25,7 @@ final class UTCDateTimeType extends DateTimeType
* @param T $value
* @param AbstractPlatform $platform
* @return (T is null ? null : string)
* @template T
* @template T<\DateTime>
* @throws ConversionException
*/
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string

View File

@@ -9,13 +9,12 @@
namespace App\Entity;
use App\Repository\AccessTokenRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_access_token')]
#[ORM\Entity(repositoryClass: AccessTokenRepository::class)]
#[ORM\Entity(repositoryClass: 'App\Repository\AccessTokenRepository')]
#[ORM\UniqueConstraint(columns: ['token'])]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(fields: ['token'])]

View File

@@ -12,7 +12,6 @@ namespace App\Entity;
use App\Doctrine\Behavior\CreatedAt;
use App\Doctrine\Behavior\CreatedTrait;
use App\Export\Annotation as Exporter;
use App\Repository\ActivityRepository;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -25,7 +24,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Index(columns: ['visible', 'project_id'])]
#[ORM\Index(columns: ['visible', 'project_id', 'name'])]
#[ORM\Index(columns: ['visible', 'name'])]
#[ORM\Entity(repositoryClass: ActivityRepository::class)]
#[ORM\Entity(repositoryClass: 'App\Repository\ActivityRepository')]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[Serializer\ExclusionPolicy('all')]
#[Serializer\VirtualProperty('ProjectName', exp: 'object.getProject() === null ? null : object.getProject().getName()', options: [new Serializer\SerializedName('parentTitle'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Activity'])])]

View File

@@ -9,7 +9,6 @@
namespace App\Entity;
use App\Repository\ActivityRateRepository;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@@ -17,7 +16,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_activities_rates')]
#[ORM\UniqueConstraint(columns: ['user_id', 'activity_id'])]
#[ORM\Entity(repositoryClass: ActivityRateRepository::class)]
#[ORM\Entity(repositoryClass: 'App\Repository\ActivityRateRepository')]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(['user', 'activity'], ignoreNull: false)]
#[Serializer\ExclusionPolicy('all')]

View File

@@ -9,14 +9,13 @@
namespace App\Entity;
use App\Repository\BookmarkRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_bookmarks')]
#[ORM\UniqueConstraint(columns: ['user_id', 'name'])]
#[ORM\Entity(repositoryClass: BookmarkRepository::class)]
#[ORM\Entity(repositoryClass: 'App\Repository\BookmarkRepository')]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(fields: ['user', 'name'])]
class Bookmark

View File

@@ -9,14 +9,13 @@
namespace App\Entity;
use App\Repository\ConfigurationRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_configuration')]
#[ORM\UniqueConstraint(columns: ['name'])]
#[ORM\Entity(repositoryClass: ConfigurationRepository::class)]
#[ORM\Entity(repositoryClass: 'App\Repository\ConfigurationRepository')]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('name')]
class Configuration

View File

@@ -12,7 +12,6 @@ namespace App\Entity;
use App\Doctrine\Behavior\CreatedAt;
use App\Doctrine\Behavior\CreatedTrait;
use App\Export\Annotation as Exporter;
use App\Repository\CustomerRepository;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -23,7 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_customers')]
#[ORM\Index(columns: ['visible'])]
#[ORM\Entity(repositoryClass: CustomerRepository::class)]
#[ORM\Entity(repositoryClass: 'App\Repository\CustomerRepository')]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[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', 'comment', 'billable'])]

View File

@@ -9,7 +9,6 @@
namespace App\Entity;
use App\Repository\CustomerRateRepository;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@@ -17,7 +16,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_customers_rates')]
#[ORM\UniqueConstraint(columns: ['user_id', 'customer_id'])]
#[ORM\Entity(repositoryClass: CustomerRateRepository::class)]
#[ORM\Entity(repositoryClass: 'App\Repository\CustomerRateRepository')]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(['user', 'customer'], ignoreNull: false)]
#[Serializer\ExclusionPolicy('all')]

View File

@@ -11,19 +11,17 @@ namespace App\Entity;
use App\Export\Annotation as Exporter;
use App\Invoice\InvoiceModel;
use App\Repository\InvoiceRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use OpenApi\Attributes as OA;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_invoices')]
#[ORM\UniqueConstraint(columns: ['invoice_number'])]
#[ORM\UniqueConstraint(columns: ['invoice_filename'])]
#[ORM\Entity(repositoryClass: InvoiceRepository::class)]
#[ORM\Entity(repositoryClass: 'App\Repository\InvoiceRepository')]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('invoiceNumber')]
#[UniqueEntity('invoiceFilename')]
@@ -67,14 +65,12 @@ class Invoice implements EntityWithMetaFields
#[Assert\NotNull]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[OA\Property(ref: '#/components/schemas/Customer')]
private ?Customer $customer = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[OA\Property(ref: '#/components/schemas/User')]
private ?User $user = null;
#[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)]
#[Assert\NotNull]

View File

@@ -9,14 +9,13 @@
namespace App\Entity;
use App\Repository\InvoiceTemplateRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_invoice_templates')]
#[ORM\UniqueConstraint(columns: ['name'])]
#[ORM\Entity(repositoryClass: InvoiceTemplateRepository::class)]
#[ORM\Entity(repositoryClass: 'App\Repository\InvoiceTemplateRepository')]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('name')]
class InvoiceTemplate

View File

@@ -109,14 +109,16 @@ trait MetaTableTypeTrait
// unchecked checkboxes / false bool would save an empty string in the database
// those cannot be searched in the database
switch ($this->type) {
case YesNoType::class:
case CheckboxType::class:
if ($value === false || $value === '' || !\is_scalar($value)) {
$value = 0;
} else {
$value = 1;
}
if (null !== $value) {
switch ($this->type) {
case YesNoType::class:
case CheckboxType::class:
if (!\is_int($value) && !\is_bool($value) && !\is_string($value)) {
throw new \InvalidArgumentException('Failed converting meta-field bool value');
} else {
$value = (string) $value;
}
}
}
if ($value === null) {

View File

@@ -12,7 +12,6 @@ namespace App\Entity;
use App\Doctrine\Behavior\CreatedAt;
use App\Doctrine\Behavior\CreatedTrait;
use App\Export\Annotation as Exporter;
use App\Repository\ProjectRepository;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -24,7 +23,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_projects')]
#[ORM\Index(columns: ['customer_id', 'visible', 'name'])]
#[ORM\Index(columns: ['customer_id', 'visible', 'id'])]
#[ORM\Entity(repositoryClass: ProjectRepository::class)]
#[ORM\Entity(repositoryClass: 'App\Repository\ProjectRepository')]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[Serializer\ExclusionPolicy('all')]
#[Serializer\VirtualProperty('CustomerName', exp: 'object.getCustomer() === null ? null : object.getCustomer().getName()', options: [new Serializer\SerializedName('parentTitle'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Project'])])]

View File

@@ -9,7 +9,6 @@
namespace App\Entity;
use App\Repository\ProjectRateRepository;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@@ -17,7 +16,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_projects_rates')]
#[ORM\UniqueConstraint(columns: ['user_id', 'project_id'])]
#[ORM\Entity(repositoryClass: ProjectRateRepository::class)]
#[ORM\Entity(repositoryClass: 'App\Repository\ProjectRateRepository')]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(['user', 'project'], ignoreNull: false)]
#[Serializer\ExclusionPolicy('all')]

View File

@@ -9,14 +9,13 @@
namespace App\Entity;
use App\Repository\RoleRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_roles')]
#[ORM\UniqueConstraint(name: 'roles_name', columns: ['name'])]
#[ORM\Entity(repositoryClass: RoleRepository::class)]
#[ORM\Entity(repositoryClass: 'App\Repository\RoleRepository')]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('name')]
class Role

View File

@@ -9,14 +9,13 @@
namespace App\Entity;
use App\Repository\RolePermissionRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_roles_permissions')]
#[ORM\UniqueConstraint(name: 'role_permission', columns: ['role_id', 'permission'])]
#[ORM\Entity(repositoryClass: RolePermissionRepository::class)]
#[ORM\Entity(repositoryClass: 'App\Repository\RolePermissionRepository')]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity(['role', 'permission'])]
class RolePermission

View File

@@ -9,7 +9,6 @@
namespace App\Entity;
use App\Repository\TagRepository;
use App\Utils\Color;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
@@ -18,7 +17,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_tags')]
#[ORM\UniqueConstraint(columns: ['name'])]
#[ORM\Entity(repositoryClass: TagRepository::class)]
#[ORM\Entity(repositoryClass: 'App\Repository\TagRepository')]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('name')]
#[Serializer\ExclusionPolicy('all')]

View File

@@ -178,13 +178,13 @@ class Team
}
/**
* @return list<User>
* @return User[]
*/
public function getTeamleads(): array
{
$leads = [];
foreach ($this->members as $member) {
if ($member->isTeamlead() && $member->getUser() !== null) {
if ($member->isTeamlead()) {
$leads[] = $member->getUser();
}
}
@@ -273,15 +273,13 @@ class Team
/**
* Returns all users in the team, both teamlead and normal member.
*
* @return list<User>
* @return User[]
*/
public function getUsers(): array
{
$users = [];
foreach ($this->members as $member) {
if ($member->getUser() !== null) {
$users[] = $member->getUser();
}
$users[] = $member->getUser();
}
return $users;

View File

@@ -11,7 +11,6 @@ namespace App\Entity;
use App\Doctrine\Behavior\ModifiedAt;
use App\Doctrine\Behavior\ModifiedTrait;
use App\Repository\TimesheetRepository;
use App\Validator\Constraints as Constraints;
use DateTime;
use DateTimeZone;
@@ -38,7 +37,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Index(columns: ['end_time', 'user', 'start_time'], name: 'IDX_TIMESHEET_TICKTAC')]
#[ORM\Index(columns: ['user', 'project_id', 'activity_id'], name: 'IDX_TIMESHEET_RECENT_ACTIVITIES')]
#[ORM\Index(columns: ['user', 'id', 'duration'], name: 'IDX_TIMESHEET_RESULT_STATS')]
#[ORM\Entity(repositoryClass: TimesheetRepository::class)]
#[ORM\Entity(repositoryClass: 'App\Repository\TimesheetRepository')]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[ORM\HasLifecycleCallbacks]
#[Serializer\ExclusionPolicy('all')]
@@ -625,9 +624,11 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
return $this;
}
public function createCopy(): Timesheet
public function createCopy(?Timesheet $timesheet = null): Timesheet
{
$timesheet = new Timesheet();
if (null === $timesheet) {
$timesheet = new Timesheet();
}
$values = get_object_vars($this);
foreach ($values as $k => $v) {
@@ -638,9 +639,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
/** @var TimesheetMeta $meta */
foreach ($this->meta as $meta) {
$tmp = clone $meta;
$tmp->setEntity($timesheet);
$timesheet->setMetaField($tmp);
$timesheet->setMetaField(clone $meta);
}
$timesheet->tags = new ArrayCollection();

View File

@@ -10,7 +10,6 @@
namespace App\Entity;
use App\Export\Annotation as Exporter;
use App\Repository\UserRepository;
use App\Utils\StringHelper;
use App\Validator\Constraints as Constraints;
use App\WorkingTime\Mode\WorkingTimeModeNone;
@@ -34,7 +33,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_users')]
#[ORM\UniqueConstraint(columns: ['username'])]
#[ORM\UniqueConstraint(columns: ['email'])]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Entity(repositoryClass: 'App\Repository\UserRepository')]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('username')]
#[UniqueEntity('email')]
@@ -753,6 +752,9 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
* This method should not be called by plugins and returns true on success or false on a failure.
*
* @internal immutable property that cannot be set by plugins
* @param bool $canSeeAllData
* @return bool
* @throws Exception
*/
public function initCanSeeAllData(bool $canSeeAllData): bool
{

View File

@@ -150,26 +150,11 @@ class UserPreference
* integer, float, string, boolean or null
*
* @param mixed $value
* @return UserPreference
*/
public function setValue($value): UserPreference
{
// unchecked checkboxes / false bool would save an empty string in the database
// those cannot be searched in the database
switch ($this->type) {
case YesNoType::class:
case CheckboxType::class:
if ($value === false || $value === '' || !\is_scalar($value)) {
$value = 0;
} else {
$value = 1;
}
}
if ($value === null) {
$this->value = $value;
} elseif (\is_scalar($value)) {
$this->value = (string) $value;
}
$this->value = $value;
return $this;
}

View File

@@ -1,17 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
/**
* Triggered right before a activity will be deleted.
*/
final class ActivityDeleteEvent extends AbstractActivityEvent
{
}

View File

@@ -1,17 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
/**
* Triggered right before a customer will be deleted.
*/
final class CustomerDeleteEvent extends AbstractCustomerEvent
{
}

View File

@@ -157,7 +157,7 @@ class PageActionsEvent extends ThemeEvent
public function addQuickImport(string $url): void
{
$this->addAction('import', ['url' => $url, 'class' => 'toolbar-action', 'title' => 'import', 'icon' => 'upload']);
$this->addAction('import', ['url' => $url, 'class' => 'toolbar-action', 'title' => 'import', 'icon' => 'upload', 'translation_domain' => 'actions']);
}
public function addQuickExport(string $url): void
@@ -172,7 +172,7 @@ class PageActionsEvent extends ThemeEvent
public function addEdit(string $url, bool $modal = true, string $class = ''): void
{
$this->addAction('edit', ['url' => $url, 'class' => ($modal ? 'modal-ajax-form' . ($class === '' ? '' : ' ' . $class) : $class), 'title' => 'edit']);
$this->addAction('edit', ['url' => $url, 'class' => ($modal ? 'modal-ajax-form' . ($class === '' ? '' : ' ' . $class) : $class), 'translation_domain' => 'actions', 'title' => 'edit']);
}
/**
@@ -180,20 +180,20 @@ class PageActionsEvent extends ThemeEvent
*/
public function addSettings(string $url): void
{
$this->addAction('settings', ['url' => $url, 'class' => 'modal-ajax-form', 'title' => 'settings', 'accesskey' => 'h']);
$this->addAction('settings', ['url' => $url, 'class' => 'modal-ajax-form', 'title' => 'settings', 'translation_domain' => 'actions', 'accesskey' => 'h']);
}
public function addConfig(string $url): void
{
$this->addAction('settings', ['url' => $url, 'title' => 'settings']);
$this->addAction('settings', ['url' => $url, 'title' => 'settings', 'translation_domain' => 'actions']);
}
public function addDelete(string $url, bool $remoteConfirm = true): void
{
if ($remoteConfirm) {
$this->addAction('trash', ['url' => $url, 'class' => 'modal-ajax-form text-red', 'title' => 'trash']);
$this->addAction('trash', ['url' => $url, 'class' => 'modal-ajax-form text-red', 'translation_domain' => 'actions', 'title' => 'trash']);
} else {
$this->addAction('trash', ['url' => $url, 'class' => 'confirmation-link text-red', 'attr' => ['data-question' => 'confirm.delete'], 'title' => 'trash']);
$this->addAction('trash', ['url' => $url, 'class' => 'confirmation-link text-red', 'attr' => ['data-question' => 'confirm.delete'], 'translation_domain' => 'actions', 'title' => 'trash']);
}
}

View File

@@ -1,17 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
/**
* Triggered right before a project will be deleted.
*/
final class ProjectDeleteEvent extends AbstractProjectEvent
{
}

View File

@@ -21,7 +21,7 @@ abstract class AbstractActionsSubscriber implements EventSubscriberInterface
{
private ?string $locale = null;
public function __construct(private readonly AuthorizationCheckerInterface $auth, private readonly UrlGeneratorInterface $urlGenerator)
public function __construct(private AuthorizationCheckerInterface $auth, private UrlGeneratorInterface $urlGenerator)
{
}

View File

@@ -29,7 +29,7 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
}
if (!$timesheet->isRunning() && $this->isGranted('start', $timesheet)) {
$event->addAction('repeat', ['title' => 'repeat', 'url' => $this->path('restart_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link dd-ts-repeat', 'attr' => ['data-payload' => '{"copy": "all"}', 'data-event' => 'kimai.timesheetStart kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.start.error', 'data-msg-success' => 'timesheet.start.success']]);
$event->addAction('repeat', ['title' => 'repeat', 'translation_domain' => 'actions', 'url' => $this->path('restart_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link dd-ts-repeat', 'attr' => ['data-payload' => '{"copy": "all"}', 'data-event' => 'kimai.timesheetStart kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.start.error', 'data-msg-success' => 'timesheet.start.success']]);
}
if ($this->isGranted('edit', $timesheet)) {
@@ -38,7 +38,7 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
if ($this->isGranted('duplicate', $timesheet)) {
$class = $event->isView('edit') ? '' : 'modal-ajax-form';
$event->addAction('copy', ['title' => 'copy', 'url' => $this->path($routeDuplicate, ['id' => $timesheet->getId()]), 'class' => $class . ' dd-ts-duplicate']);
$event->addAction('copy', ['title' => 'copy', 'translation_domain' => 'actions', 'url' => $this->path($routeDuplicate, ['id' => $timesheet->getId()]), 'class' => $class . ' dd-ts-duplicate']);
}
if ($event->countActions() > 0) {
@@ -49,6 +49,7 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
$event->addAction('trash', [
'url' => $this->path('delete_timesheet', ['id' => $timesheet->getId()]),
'class' => 'api-link text-red dd-ts-trash',
'translation_domain' => 'actions',
'attr' => [
'data-event' => 'kimai.timesheetDelete',
'data-method' => 'DELETE',

View File

@@ -31,7 +31,7 @@ final class ActivitySubscriber extends AbstractActionsSubscriber
}
if (!$event->isView('activity_details') && $this->isGranted('view', $activity)) {
$event->addAction('details', ['title' => 'details', 'url' => $this->path('activity_details', ['id' => $activity->getId()])]);
$event->addAction('details', ['title' => 'details', 'translation_domain' => 'actions', 'url' => $this->path('activity_details', ['id' => $activity->getId()])]);
}
if ($this->isGranted('edit', $activity)) {
@@ -40,7 +40,7 @@ final class ActivitySubscriber extends AbstractActionsSubscriber
if ($this->isGranted('permissions', $activity)) {
$class = $event->isView('permissions') ? '' : 'modal-ajax-form';
$event->addAction('permissions', ['title' => 'permissions', 'url' => $this->path('admin_activity_permissions', ['id' => $activity->getId()]), 'class' => $class]);
$event->addAction('permissions', ['title' => 'permissions', 'translation_domain' => 'actions', 'url' => $this->path('admin_activity_permissions', ['id' => $activity->getId()]), 'class' => $class]);
}
if ($event->countActions() > 0) {
@@ -53,7 +53,7 @@ final class ActivitySubscriber extends AbstractActionsSubscriber
$parameters['customers[]'] = $activity->getProject()->getCustomer()->getId();
$parameters['projects[]'] = $activity->getProject()->getId();
}
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', $parameters)]);
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet', $parameters)]);
}
if ($event->hasSubmenu('filter')) {
@@ -65,7 +65,7 @@ final class ActivitySubscriber extends AbstractActionsSubscriber
if (!$activity->isGlobal()) {
$parameters['project'] = $activity->getProject()->getId();
}
$event->addAction('create-timesheet', ['title' => 'create-timesheet', 'icon' => 'start', 'url' => $this->path('admin_timesheet_create', $parameters), 'class' => 'modal-ajax-form']);
$event->addAction('create-timesheet', ['title' => 'create-timesheet', 'translation_domain' => 'actions', 'icon' => 'start', 'url' => $this->path('admin_timesheet_create', $parameters), 'class' => 'modal-ajax-form']);
}
if (($event->isIndexView() || $event->isView('project_details')) && $this->isGranted('delete', $activity)) {

View File

@@ -34,7 +34,7 @@ final class CustomerSubscriber extends AbstractActionsSubscriber
$isListingView = $event->isIndexView() || $event->isCustomView();
if (!$event->isView('customer_details') && $canView) {
$event->addAction('details', ['title' => 'details', 'url' => $this->path('customer_details', ['id' => $customer->getId()])]);
$event->addAction('details', ['title' => 'details', 'translation_domain' => 'actions', 'url' => $this->path('customer_details', ['id' => $customer->getId()])]);
}
if ($this->isGranted('edit', $customer)) {
@@ -43,7 +43,7 @@ final class CustomerSubscriber extends AbstractActionsSubscriber
if ($this->isGranted('permissions', $customer)) {
$class = $event->isView('permissions') ? '' : 'modal-ajax-form';
$event->addAction('permissions', ['title' => 'permissions', 'url' => $this->path('admin_customer_permissions', ['id' => $customer->getId()]), 'class' => $class]);
$event->addAction('permissions', ['title' => 'permissions', 'translation_domain' => 'actions', 'url' => $this->path('admin_customer_permissions', ['id' => $customer->getId()]), 'class' => $class]);
}
if ($isListingView) {
@@ -61,15 +61,15 @@ final class CustomerSubscriber extends AbstractActionsSubscriber
}
if ($this->isGranted('view_project') || $this->isGranted('view_teamlead_project') || $this->isGranted('view_team_project')) {
$event->addActionToSubmenu('filter', 'project', ['title' => 'project.filter', 'url' => $this->path('admin_project', ['customers[]' => $customer->getId()])]);
$event->addActionToSubmenu('filter', 'project', ['title' => 'project.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_project', ['customers[]' => $customer->getId()])]);
}
if ($this->isGranted('view_activity')) {
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activity.filter', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId()])]);
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activity.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId()])]);
}
if ($this->isGranted('view_other_timesheet')) {
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId()])]);
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId()])]);
}
if ($event->hasSubmenu('filter')) {

View File

@@ -56,7 +56,7 @@ final class InvoiceSubscriber extends AbstractActionsSubscriber
$allowDelete = $this->isGranted('delete_invoice');
if (!$invoice->isCanceled()) {
$id = $allowDelete ? 'invoice.cancel' : 'trash';
$event->addAction($id, ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'canceled', 'token' => $payload['token']]), 'title' => 'invoice.cancel']);
$event->addAction($id, ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'canceled', 'token' => $payload['token']]), 'title' => 'invoice.cancel', 'translation_domain' => 'actions']);
}
if ($this->isGranted('delete_invoice')) {

View File

@@ -34,7 +34,7 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
$isListingView = $event->isIndexView() || $event->isCustomView();
if (!$event->isView('project_details') && $this->isGranted('view', $project)) {
$event->addAction('details', ['title' => 'details', 'url' => $this->path('project_details', ['id' => $project->getId()])]);
$event->addAction('details', ['title' => 'details', 'translation_domain' => 'actions', 'url' => $this->path('project_details', ['id' => $project->getId()])]);
}
if ($this->isGranted('edit', $project)) {
@@ -43,7 +43,7 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
if ($this->isGranted('permissions', $project)) {
$class = $event->isView('permissions') ? '' : 'modal-ajax-form';
$event->addAction('permissions', ['title' => 'permissions', 'url' => $this->path('admin_project_permissions', ['id' => $project->getId()]), 'class' => $class]);
$event->addAction('permissions', ['title' => 'permissions', 'translation_domain' => 'actions', 'url' => $this->path('admin_project_permissions', ['id' => $project->getId()]), 'class' => $class]);
}
if ($event->countActions() > 0) {
@@ -51,11 +51,11 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
}
if ($this->isGranted('view_activity')) {
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activity.filter', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activity.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
}
if ($this->isGranted('view_other_timesheet')) {
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
}
if ($this->isGranted('create_export')) {
@@ -79,7 +79,7 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
if (\array_key_exists('token', $payload) && $this->isGranted('edit', $project) && $this->isGranted('create_project')) {
$event->addAction(
'copy',
['title' => 'copy', 'url' => $this->path('admin_project_duplicate', ['id' => $project->getId(), 'token' => $payload['token']])]
['title' => 'copy', 'translation_domain' => 'actions', 'url' => $this->path('admin_project_duplicate', ['id' => $project->getId(), 'token' => $payload['token']])]
);
}

View File

@@ -46,13 +46,14 @@ final class TagSubscriber extends AbstractActionsSubscriber
}
if ($this->isGranted('view_other_timesheet')) {
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', ['tags' => $name])]);
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet', ['tags' => $name])]);
}
if ($event->isIndexView() && $this->isGranted('delete_tag')) {
$event->addAction('trash', [
'url' => $this->path('delete_tag', ['id' => $id]),
'class' => 'api-link text-red',
'translation_domain' => 'actions',
'attr' => [
'data-event' => 'kimai.tagDelete kimai.tagUpdate',
'data-method' => 'DELETE',

View File

@@ -36,7 +36,7 @@ final class TeamSubscriber extends AbstractActionsSubscriber
}
if ($this->isGranted('create_team')) {
$event->addAction('copy', ['url' => $this->path('team_duplicate', ['id' => $team->getId()]), 'title' => 'copy', 'class' => 'modal-ajax-form']);
$event->addAction('copy', ['url' => $this->path('team_duplicate', ['id' => $team->getId()]), 'title' => 'copy', 'translation_domain' => 'actions', 'class' => 'modal-ajax-form']);
}
}
@@ -44,6 +44,7 @@ final class TeamSubscriber extends AbstractActionsSubscriber
$event->addAction('trash', [
'url' => $this->path('delete_team', ['id' => $team->getId()]),
'class' => 'api-link text-red',
'translation_domain' => 'actions',
'attr' => [
'data-event' => 'kimai.teamDelete kimai.teamUpdate',
'data-method' => 'DELETE',

View File

@@ -22,7 +22,7 @@ final class TimesheetsTeamSubscriber extends AbstractActionsSubscriber
{
if ($this->isGranted('create_other_timesheet')) {
$event->addAction('create', ['title' => 'create', 'url' => $this->path('admin_timesheet_create'), 'class' => 'create-ts modal-ajax-form']);
$event->addAction('multi-user', ['title' => 'create-timesheet-multiuser', 'url' => $this->path('admin_timesheet_create_multiuser'), 'class' => 'create-ts-mu modal-ajax-form', 'icon' => 'fas fa-user-plus']);
$event->addAction('multi-user', ['title' => 'create-timesheet-multiuser', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet_create_multiuser'), 'class' => 'create-ts-mu modal-ajax-form', 'icon' => 'fas fa-user-plus']);
}
if ($this->isGranted('export_other_timesheet')) {

View File

@@ -31,7 +31,7 @@ final class UserFormsSubscriber extends AbstractActionsSubscriber
}
if ($this->isGranted('edit', $user)) {
$event->addAction('edit', ['url' => $this->path('user_profile_edit', ['username' => $user->getUserIdentifier()]), 'title' => 'profile-stats']);
$event->addAction('edit', ['url' => $this->path('user_profile_edit', ['username' => $user->getUserIdentifier()]), 'title' => 'profile-stats', 'translation_domain' => 'actions']);
}
if ($this->isGranted('preferences', $user)) {
$event->addConfig($this->path('user_profile_preferences', ['username' => $user->getUserIdentifier()]));

View File

@@ -40,7 +40,7 @@ final class UserSubscriber extends AbstractActionsSubscriber
}
if ($this->isGranted('view', $user)) {
$event->addAction('profile-stats', ['icon' => 'avatar', 'url' => $this->path('user_profile', ['username' => $user->getUserIdentifier()]), 'title' => 'profile-stats']);
$event->addAction('profile-stats', ['icon' => 'avatar', 'url' => $this->path('user_profile', ['username' => $user->getUserIdentifier()]), 'translation_domain' => 'actions', 'title' => 'profile-stats']);
$event->addDivider();
}
@@ -62,7 +62,7 @@ final class UserSubscriber extends AbstractActionsSubscriber
}
if ($user->isEnabled() && $this->isGranted('view_other_timesheet')) {
$event->addActionToSubmenu('filter', 'timesheet', ['url' => $this->path('admin_timesheet', ['users[]' => $user->getId()]), 'title' => 'timesheet.filter']);
$event->addActionToSubmenu('filter', 'timesheet', ['url' => $this->path('admin_timesheet', ['users[]' => $user->getId()]), 'title' => 'timesheet.filter', 'translation_domain' => 'actions']);
}
if ($this->isGranted('view_team')) {

View File

@@ -28,7 +28,7 @@ final class AnnotationExtractor implements ExtractorInterface
/**
* @param string $value
* @return list<ColumnDefinition>
* @return ColumnDefinition[]
* @throws ExtractorException
*/
public function extract($value): array
@@ -154,7 +154,11 @@ final class AnnotationExtractor implements ExtractorInterface
}
}
$columns = array_filter($columns, function ($value) { return $value !== null; });
foreach ($columns as $name => $definition) {
if (null === $definition) {
unset($columns[$name]);
}
}
return array_values($columns);
}

View File

@@ -11,12 +11,11 @@ namespace App\Export;
use App\Entity\Timesheet;
use App\Repository\Query\ExportQuery;
use App\Repository\Query\TimesheetQueryHint;
use App\Repository\TimesheetRepository;
final class TimesheetExportRepository implements ExportRepositoryInterface
{
public function __construct(private readonly TimesheetRepository $repository)
public function __construct(private TimesheetRepository $repository)
{
}
@@ -42,12 +41,7 @@ final class TimesheetExportRepository implements ExportRepositoryInterface
public function getExportItemsForQuery(ExportQuery $query): iterable
{
$query->addQueryHint(TimesheetQueryHint::CUSTOMER_META_FIELDS);
$query->addQueryHint(TimesheetQueryHint::PROJECT_META_FIELDS);
$query->addQueryHint(TimesheetQueryHint::ACTIVITY_META_FIELDS);
$query->addQueryHint(TimesheetQueryHint::USER_PREFERENCES);
return $this->repository->getTimesheetResult($query)->getResults();
return $this->repository->getTimesheetsForQuery($query, true);
}
public function getType(): string

View File

@@ -29,7 +29,6 @@ final class MultiUpdateTable extends AbstractType
$builder->add('entities', HiddenType::class, [
'required' => false,
'attr' => ['class' => 'multi_update_ids']
]);
$builder->get('entities')->addModelTransformer(

View File

@@ -38,8 +38,6 @@ final class ActivityType extends AbstractType
public function groupBy(Activity $activity, $key, $index): string
{
if (null === $activity->getProject()) {
// this creates a optgroup with an empty title. previously this was null, which resulted in options without optgroup
// and those are ordered by Tomselect at the top - so globals always came first, see #4674
return '';
}

View File

@@ -109,7 +109,7 @@ class Kernel extends BaseKernel
throw new \Exception(\sprintf('Bundle "%s" does not implement %s, which is not supported since 2.0.', $bundleName, PluginInterface::class));
}
$meta = PluginMetadata::createFromPath($fullPath);
$meta = new PluginMetadata($fullPath);
if ($meta->getKimaiVersion() > Constants::VERSION_ID) {
throw new \Exception(\sprintf('Bundle "%s" requires minimum Kimai version %s, but yours is lower: %s (%s). Please update Kimai or use a lower Plugin version.', $bundleName, $meta->getKimaiVersion(), Constants::VERSION, Constants::VERSION_ID));
@@ -169,8 +169,11 @@ class Kernel extends BaseKernel
$routes->import($configDir . '/routes/' . $this->environment . '/*.yaml');
}
// load application routes
$routes->import($configDir . '/routes.yaml');
foreach ($this->getBundles() as $bundle) {
if ($bundle instanceof PluginInterface || str_contains(\get_class($bundle), 'KimaiPlugin\\')) {
if (str_contains(\get_class($bundle), 'KimaiPlugin\\')) {
if (is_dir($bundle->getPath() . '/Resources/config/')) {
$routes->import($bundle->getPath() . '/Resources/config/routes' . self::CONFIG_EXTS);
} elseif (is_dir($bundle->getPath() . '/config/')) {
@@ -178,8 +181,5 @@ class Kernel extends BaseKernel
}
}
}
// load application routes as last one, so bundles cannot override application ones
$routes->import($configDir . '/routes.yaml');
}
}

View File

@@ -13,7 +13,6 @@ use App\Configuration\MailConfiguration;
use App\Entity\User;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\RawMessage;
@@ -32,11 +31,7 @@ final class KimaiMailer implements MailerInterface
}
if (\count($message->getFrom()) === 0) {
$fallback = $this->configuration->getFromAddress();
if ($fallback === null) {
throw new \RuntimeException('Missing email "from" address');
}
$message->from(new Address($fallback, 'Kimai'));
$message->from($this->configuration->getFromAddress());
}
$this->mailer->send($message);

View File

@@ -17,7 +17,7 @@ use DateTimeInterface;
final class MonthlyStatistic implements DateStatisticInterface
{
/**
* @var array<string, array<int<1, 12>, StatisticDate>>
* @var array<string|int, array<int<1, 12>, StatisticDate>>
*/
private array $years = [];
private DateTimeInterface $begin;
@@ -50,7 +50,7 @@ final class MonthlyStatistic implements DateStatisticInterface
$day = (int) $begin->format('d');
while ($tmp < $this->end) {
$curYear = (string) $tmp->format('Y');
$curYear = $tmp->format('Y');
if (!isset($years[$curYear])) {
$year = [];
for ($i = 1; $i < 13; $i++) {
@@ -67,7 +67,7 @@ final class MonthlyStatistic implements DateStatisticInterface
}
$tmp->modify('+1 month');
}
$this->years = $years; // @phpstan-ignore assign.propertyType
$this->years = $years;
}
/**

View File

@@ -14,6 +14,17 @@ use Symfony\Component\HttpKernel\DependencyInjection\Extension;
abstract class AbstractPluginExtension extends Extension
{
protected function registerIcon(ContainerBuilder $container, string $name, string $icon): void
{
$container->setParameter(
'tabler_bundle.icons',
array_merge(
$container->getParameter('tabler_bundle.icons'),
[$name => $icon]
)
);
}
protected function registerBundleConfiguration(ContainerBuilder $container, array $configs): void
{
$bundleConfig = [$this->getAlias() => $configs];

View File

@@ -1,30 +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\Plugin;
/**
* @internaö
*/
final class Package
{
public function __construct(private readonly \SplFileInfo $packageFile, private readonly PluginMetadata $pluginMetadata)
{
}
public function getPackageFile(): \SplFileInfo
{
return $this->packageFile;
}
public function getMetadata(): PluginMetadata
{
return $this->pluginMetadata;
}
}

View File

@@ -1,155 +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\Plugin;
/**
* @internal
*/
final class PackageManager
{
public const PACKAGE_DIR = 'var/packages';
public function __construct(private readonly string $projectDirectory)
{
}
/**
* @return Package[]
*/
public function getAvailablePackages(): array
{
return $this->findAvailablePackages($this->projectDirectory . '/' . self::PACKAGE_DIR);
}
/**
* Copied from Composer\Repository\ArtifactRepository
* @see https://github.com/composer/composer/blob/main/src/Composer/Repository/ArtifactRepository.php
*
* @return Package[]
*/
private function findAvailablePackages(string $path): array
{
$packages = [];
$directory = new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::FOLLOW_SYMLINKS);
$iterator = new \RecursiveIteratorIterator($directory);
$regex = new \RegexIterator($iterator, '/^.+\.zip$/i');
/** @var \SplFileInfo $file */
foreach ($regex as $file) {
if (!$file->isFile()) {
continue;
}
$package = $this->getComposerJson($file->getPathname());
if ($package === null) {
continue;
}
$content = json_decode($package, true);
if (\JSON_ERROR_NONE !== json_last_error() || !\is_array($content)) {
throw new \RuntimeException('Failed to parse composer.json file in: ' . $file->getPathname());
}
$packages[] = new Package($file, PluginMetadata::createFromArray($content));
}
return $packages;
}
/**
* Copied from Composer\Util\Zip
* @see https://github.com/composer/composer/blob/main/src/Composer/Util/Zip.php
*/
private function getComposerJson(string $pathToZip): ?string
{
if (!\extension_loaded('zip')) {
throw new \RuntimeException('The Zip Util requires PHP\'s zip extension');
}
$zip = new \ZipArchive();
if ($zip->open($pathToZip) !== true) {
return null;
}
if (0 === $zip->numFiles) {
$zip->close();
return null;
}
$foundFileIndex = self::locateFile($zip, 'composer.json');
$content = null;
$configurationFileName = $zip->getNameIndex($foundFileIndex);
if ($configurationFileName !== false) {
$stream = $zip->getStream($configurationFileName);
if (false !== $stream) {
$content = stream_get_contents($stream);
if ($content === false) {
$content = null;
}
}
}
$zip->close();
return $content;
}
/**
* Copied from Composer\Util\Zip
* @see https://github.com/composer/composer/blob/main/src/Composer/Util/Zip.php
*/
private static function locateFile(\ZipArchive $zip, string $filename): int
{
// return root composer.json if it is there and is a file
if (false !== ($index = $zip->locateName($filename)) && $zip->getFromIndex($index) !== false) {
return $index;
}
$topLevelPaths = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if ($name === false) {
continue;
}
$dirname = \dirname($name);
// ignore OSX specific resource fork folder
if (strpos($name, '__MACOSX') !== false) {
continue;
}
// handle archives with proper TOC
if ($dirname === '.') {
$topLevelPaths[$name] = true;
if (\count($topLevelPaths) > 1) {
throw new \RuntimeException('Archive has more than one top level directories, and no composer.json was found on the top level, so it\'s an invalid archive. Top level paths found were: ' . implode(',', array_keys($topLevelPaths)));
}
continue;
}
// handle archives which do not have a TOC record for the directory itself
if (false === strpos($dirname, '\\') && false === strpos($dirname, '/')) {
$topLevelPaths[$dirname . '/'] = true;
if (\count($topLevelPaths) > 1) {
throw new \RuntimeException('Archive has more than one top level directories, and no composer.json was found on the top level, so it\'s an invalid archive. Top level paths found were: ' . implode(',', array_keys($topLevelPaths)));
}
}
}
if ($topLevelPaths && false !== ($index = $zip->locateName(key($topLevelPaths) . $filename)) && $zip->getFromIndex($index) !== false) {
return $index;
}
throw new \RuntimeException('No composer.json found either at the top level or within the topmost directory');
}
}

View File

@@ -20,7 +20,7 @@ final class Plugin
public function getMetadata(): PluginMetadata
{
if ($this->metadata === null) {
$this->metadata = PluginMetadata::createFromPath($this->getPath());
$this->metadata = new PluginMetadata($this->getPath());
}
return $this->metadata;
@@ -33,7 +33,12 @@ final class Plugin
public function getName(): string
{
return $this->getMetadata()->getName();
$meta = $this->getMetadata();
if ($meta->getName() !== null) {
return $meta->getName();
}
return $this->getId();
}
public function getId(): string

View File

@@ -13,79 +13,57 @@ use App\Constants;
class PluginMetadata
{
private string $package;
private string $version;
private int $kimaiVersion;
private string $homepage;
private string $description;
private string $name;
private ?string $version = null;
private ?int $kimaiVersion = null;
private ?string $homepage = null;
private ?string $description = null;
private ?string $name = null;
public static function createFromPath(string $path): self
/**
* @throws \Exception
*/
public function __construct(string $path)
{
if (!is_dir($path) || !is_readable($path)) {
throw new \Exception(\sprintf('Bundle directory "%s" cannot be accessed.', $path));
}
$pluginName = basename($path);
$composer = $path . '/composer.json';
if (!file_exists($composer) || !is_readable($composer)) {
throw new \Exception('Bundle does not ship composer.json, which is required since 2.0.');
throw new \Exception(\sprintf('Bundle "%s" does not ship composer.json, which is required since 2.0.', $pluginName));
}
/** @var array<mixed>|null $json */
$json = json_decode(file_get_contents($composer), true);
if ($json === null) {
throw new \Exception('Could not parse composer.json, invalid JSON?');
}
return self::createFromArray($json);
}
/**
* @param array<mixed> $json
*/
public static function createFromArray(array $json): self
{
if (!\array_key_exists('extra', $json)) {
throw new \Exception('Bundle "%s" does not define an "extra" node in composer.json, which is required since 2.0.');
throw new \Exception(\sprintf('Bundle "%s" does not define an "extra" node in composer.json, which is required since 2.0.', $pluginName));
}
if (!\array_key_exists('kimai', $json['extra'])) {
throw new \Exception('Bundle does not define the "extra.kimai" node in composer.json, which is required since 2.0.');
throw new \Exception(\sprintf('Bundle "%s" does not define the "extra.kimai" node in composer.json, which is required since 2.0.', $pluginName));
}
if (!\array_key_exists('require', $json['extra']['kimai'])) {
throw new \Exception('Bundle does not define the minimum Kimai version in "extra.kimai.required" in composer.json, which is required since 2.0.');
throw new \Exception(\sprintf('Bundle "%s" does not define the minimum Kimai version in "extra.kimai.required" in composer.json, which is required since 2.0.', $pluginName));
}
if (!\array_key_exists('name', $json['extra']['kimai'])) {
throw new \Exception('Bundle does not define its name in "extra.kimai.name" in composer.json, which is required since 2.0.');
throw new \Exception(\sprintf('Bundle "%s" does not define its name in "extra.kimai.name" in composer.json, which is required since 2.0.', $pluginName));
}
if (!\is_int($json['extra']['kimai']['require'])) {
throw new \Exception('Bundle defines an invalid Kimai minimum version in extra.kimai.require. Please provide an integer as in Constants::VERSION_ID.');
throw new \Exception(\sprintf('Bundle "%s" defines an invalid Kimai minimum version in extra.kimai.require. Please provide an integer as in Constants::VERSION_ID.', $pluginName));
}
$meta = new self();
$meta->package = $json['name'] ?? '';
$meta->description = $json['description'] ?? '';
$meta->homepage = $json['homepage'] ?? Constants::HOMEPAGE . '/store/';
$meta->name = $json['extra']['kimai']['name'];
$meta->kimaiVersion = $json['extra']['kimai']['require'];
$this->description = $json['description'] ?? '';
$this->homepage = $json['homepage'] ?? Constants::HOMEPAGE . '/store/';
$this->name = $json['extra']['kimai']['name'];
$this->kimaiVersion = $json['extra']['kimai']['require'];
// the version field is required if we use composer to install a plugin via var/packages/
$meta->version = $json['extra']['kimai']['version'] ?? ($json['version'] ?? 'unknown');
return $meta;
}
private function __construct() {}
public function getPackage(): string
{
return $this->package;
$this->version = $json['extra']['kimai']['version'] ?? ($json['version'] ?? 'unknown');
}
public function getDescription(): ?string
@@ -93,22 +71,22 @@ class PluginMetadata
return $this->description;
}
public function getVersion(): string
public function getVersion(): ?string
{
return $this->version;
}
public function getKimaiVersion(): int
public function getKimaiVersion(): ?int
{
return $this->kimaiVersion;
}
public function getHomepage(): string
public function getHomepage(): ?string
{
return $this->homepage;
}
public function getName(): string
public function getName(): ?string
{
return $this->name;
}

View File

@@ -13,7 +13,6 @@ use App\Entity\Project;
use App\Repository\ActivityRateRepository;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRateRepository;
use App\Repository\Query\ActivityQuery;
final class ProjectDuplicationService
{
@@ -54,11 +53,7 @@ final class ProjectDuplicationService
$this->projectRateRepository->saveRate($newRate);
}
$query = new ActivityQuery();
$query->addProject($project);
$query->setExcludeGlobals(true);
$allActivities = $this->activityRepository->getActivitiesForQuery($query);
$allActivities = $this->activityRepository->findByProject($project);
foreach ($allActivities as $activity) {
$newActivity = clone $activity;
$newActivity->setProject($newProject);

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