Compare commits
3 Commits
2.7.0
...
customer-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1cc5f2587 | ||
|
|
6b672a23b4 | ||
|
|
900254b83a |
@@ -1,22 +0,0 @@
|
||||
<VirtualHost *:8001>
|
||||
ServerAdmin webmaster@localhost
|
||||
DocumentRoot /opt/kimai/public
|
||||
|
||||
PassEnv DATABASE_PREFIX
|
||||
PassEnv MAILER_FROM
|
||||
PassEnv APP_ENV
|
||||
PassEnv APP_SECRET
|
||||
PassEnv DATABASE_URL
|
||||
PassEnv MAILER_URL
|
||||
PassEnv TRUSTED_PROXIES
|
||||
PassEnv TRUSTED_HOSTS
|
||||
|
||||
<Directory "/opt/kimai/public">
|
||||
Require all granted
|
||||
DirectoryIndex index.php
|
||||
AllowOverride All
|
||||
</Directory>
|
||||
|
||||
</VirtualHost>
|
||||
|
||||
ServerName localhost
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
$DB_HOST = $argv[1];
|
||||
$DB_BASE = $argv[2];
|
||||
$DB_PORT = $argv[3];
|
||||
$DB_USER = $argv[4];
|
||||
$DB_PASS = $argv[5];
|
||||
|
||||
echo "Testing DB:";
|
||||
echo "*";
|
||||
echo "* new \PDO(mysql:host=$DB_HOST;dbname=$DB_BASE;port=$DB_PORT, $DB_USER, $DB_PASS, [ \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION ]);";
|
||||
echo "*";
|
||||
|
||||
try {
|
||||
$pdo = new \PDO("mysql:host=$DB_HOST;dbname=$DB_BASE;port=$DB_PORT", "$DB_USER", "$DB_PASS", [
|
||||
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION
|
||||
]);
|
||||
} catch(\Exception $ex) {
|
||||
switch ($ex->getCode()) {
|
||||
// we can immediately stop startup here and show the error message
|
||||
case 1045:
|
||||
echo 'Access denied (1045)';
|
||||
die(1);
|
||||
// we can immediately stop startup here and show the error message
|
||||
case 1049:
|
||||
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) {
|
||||
echo 'Unknown database (0-1049)';
|
||||
die(3);
|
||||
}
|
||||
switch ($ex->getMessage()) {
|
||||
// eg. no response (fw) - the startup script should retry it a couple of times
|
||||
case 'SQLSTATE[HY000] [2002] Operation timed out':
|
||||
echo 'Operation timed out (0-2002)';
|
||||
die(4);
|
||||
// special case "localhost" with a stopped db server (should not happen in docker compose setup)
|
||||
case 'SQLSTATE[HY000] [2002] No such file or directory':
|
||||
echo 'Connection could not be established (0-2002)';
|
||||
die(5);
|
||||
// using IP with stopped db server - the startup script should retry it a couple of times
|
||||
case 'SQLSTATE[HY000] [2002] Connection refused':
|
||||
echo 'Connection refused (0-2002)';
|
||||
die(5);
|
||||
}
|
||||
echo $ex->getMessage() . " (0)";
|
||||
die(7);
|
||||
default:
|
||||
// unknown error
|
||||
echo $ex->getMessage() . " (?)";
|
||||
die(10);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
when@prod:
|
||||
monolog:
|
||||
channels: ["deprecation"]
|
||||
handlers:
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
handler: nested
|
||||
excluded_http_codes: [403, 404]
|
||||
nested:
|
||||
type: stream
|
||||
level: info
|
||||
path: php://stderr
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine"]
|
||||
deprecation:
|
||||
type: stream
|
||||
channels: ["deprecation"]
|
||||
path: php://stderr
|
||||
|
||||
when@dev:
|
||||
monolog:
|
||||
channels: ["deprecation"]
|
||||
handlers:
|
||||
main:
|
||||
type: stream
|
||||
path: php://stderr
|
||||
level: info
|
||||
channels: ["!event"]
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine", "!console"]
|
||||
deprecation:
|
||||
type: stream
|
||||
channels: ["deprecation"]
|
||||
path: php://stderr
|
||||
|
||||
when@test:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: stream
|
||||
path: php://stderr
|
||||
level: info
|
||||
channels: ["!event"]
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
/startup.sh 2>&1
|
||||
echo $$ > /tmp/startup.pid
|
||||
echo "Waiting for kimai to install"
|
||||
while [ ! -f /opt/kimai/var/installed ]; do
|
||||
echo -n ". "
|
||||
sleep 5
|
||||
done
|
||||
echo
|
||||
|
||||
# Test FPM CGI
|
||||
if [ -f /use_fpm ]; then
|
||||
|
||||
export SCRIPT_NAME=/opt/kimai/public/index.php
|
||||
export SCRIPT_FILENAME=/opt/kimai/public/index.php
|
||||
export REQUEST_METHOD=GET
|
||||
export SERVER_ADDR=localhost
|
||||
|
||||
COUNT=0
|
||||
until cgi-fcgi -bind -connect 127.0.0.1:9000 &> /dev/null
|
||||
do
|
||||
COUNT=$((COUNT+1))
|
||||
echo "Waiting for FPM Server to start (${COUNT})"
|
||||
sleep 3
|
||||
if [ "$COUNT" -gt 5 ]; then
|
||||
echo "FPM Failed to start."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
fi
|
||||
|
||||
# Test Apache/httpd
|
||||
if [ -f /use_apache ]; then
|
||||
|
||||
COUNT=0
|
||||
until curl -s -o /dev/null http://localhost:8001
|
||||
do
|
||||
COUNT=$((COUNT+1))
|
||||
echo "Waiting for Apache/HTTP to start (${COUNT})" &> /dev/null
|
||||
sleep 3
|
||||
if [ "$COUNT" -gt 5 ]; then
|
||||
echo "Apache/HTTP failed to start."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
fi
|
||||
|
||||
# Test PHP/Kimai
|
||||
/opt/kimai/bin/console kimai:version
|
||||
if [ $? != 0 ]; then
|
||||
echo "PHP/Kimai not responding"
|
||||
exit 1
|
||||
fi
|
||||
kill $(cat /tmp/startup.pid)
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/bin/bash -x
|
||||
|
||||
function waitForDB() {
|
||||
# Parse sql connection data
|
||||
if [ ! -z "$DATABASE_URL" ]; then
|
||||
DB_TYPE=$(awk -F '[/:@]' '{print $1}' <<< "$DATABASE_URL")
|
||||
DB_USER=$(awk -F '[/:@]' '{print $4}' <<< "$DATABASE_URL")
|
||||
DB_PASS=$(awk -F '[/:@]' '{print $5}' <<< "$DATABASE_URL")
|
||||
DB_HOST=$(awk -F '[/:@]' '{print $6}' <<< "$DATABASE_URL")
|
||||
DB_PORT=$(awk -F '[/:@]' '{print $7}' <<< "$DATABASE_URL")
|
||||
DB_BASE=$(awk -F '[/?]' '{print $4}' <<< "$DATABASE_URL")
|
||||
else
|
||||
DB_TYPE=${DB_TYPE:mysql}
|
||||
if [ "$DB_TYPE" == "mysql" ]; then
|
||||
export DATABASE_URL="${DB_TYPE}://${DB_USER:=kimai}:${DB_PASS:=kimai}@${DB_HOST:=sqldb}:${DB_PORT:=3306}/${DB_BASE:=kimai}"
|
||||
else
|
||||
echo "Unknown database type, cannot proceed. Only 'mysql' is supported, received: [$DB_TYPE]"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
re='^[0-9]+$'
|
||||
if ! [[ $DB_PORT =~ $re ]] ; then
|
||||
DB_PORT=3306
|
||||
fi
|
||||
|
||||
echo "Wait for MySQL DB connection ..."
|
||||
until php /dbtest.php $DB_HOST $DB_BASE $DB_PORT $DB_USER $DB_PASS; do
|
||||
echo Checking DB: $?
|
||||
sleep 3
|
||||
done
|
||||
echo "Connection established"
|
||||
}
|
||||
|
||||
function handleStartup() {
|
||||
# These are idempotent, run them anyway
|
||||
/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 superadmin "$ADMINMAIL" ROLE_SUPER_ADMIN "$ADMINPASS"
|
||||
fi
|
||||
echo "$KIMAI" > /opt/kimai/var/installed
|
||||
echo "Kimai2 ready"
|
||||
}
|
||||
|
||||
function runServer() {
|
||||
# Just while I'm fixing things
|
||||
/opt/kimai/bin/console kimai:reload --env="$APP_ENV"
|
||||
chown -R $USER_ID:$GROUP_ID /opt/kimai/var
|
||||
if [ -e /use_apache ]; then
|
||||
exec /usr/sbin/apache2 -D FOREGROUND
|
||||
elif [ -e /use_fpm ]; then
|
||||
exec php-fpm
|
||||
else
|
||||
echo "Error, unknown server type"
|
||||
fi
|
||||
}
|
||||
|
||||
waitForDB
|
||||
handleStartup
|
||||
runServer
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/bin/bash -x
|
||||
|
||||
KIMAI=$(cat /opt/kimai/version.txt)
|
||||
echo $KIMAI
|
||||
|
||||
|
||||
function config() {
|
||||
# set mem limits and copy in custom logger config
|
||||
if [ -z "$memory_limit" ]; then
|
||||
memory_limit=256M
|
||||
fi
|
||||
|
||||
|
||||
# Parse sql connection data
|
||||
if [ ! -z "$DATABASE_URL" ]; then
|
||||
DB_TYPE=$(awk -F '[/:@]' '{print $1}' <<< "$DATABASE_URL")
|
||||
DB_USER=$(awk -F '[/:@]' '{print $4}' <<< "$DATABASE_URL")
|
||||
DB_PASS=$(awk -F '[/:@]' '{print $5}' <<< "$DATABASE_URL")
|
||||
DB_HOST=$(awk -F '[/:@]' '{print $6}' <<< "$DATABASE_URL")
|
||||
DB_PORT=$(awk -F '[/:@]' '{print $7}' <<< "$DATABASE_URL")
|
||||
DB_BASE=$(awk -F '[/?]' '{print $4}' <<< "$DATABASE_URL")
|
||||
else
|
||||
DB_TYPE=${DB_TYPE:mysql}
|
||||
if [ "$DB_TYPE" == "mysql" ]; then
|
||||
export DATABASE_URL="${DB_TYPE}://${DB_USER:=kimai}:${DB_PASS:=kimai}@${DB_HOST:=sqldb}:${DB_PORT:=3306}/${DB_BASE:=kimai}"
|
||||
else
|
||||
echo "Unknown database type, cannot proceed. Only 'mysql' is supported, received: [$DB_TYPE]"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
re='^[0-9]+$'
|
||||
if ! [[ $DB_PORT =~ $re ]] ; then
|
||||
DB_PORT=3306
|
||||
fi
|
||||
|
||||
echo "Wait for MySQL DB connection ..."
|
||||
until php /dbtest.php $DB_HOST $DB_BASE $DB_PORT $DB_USER $DB_PASS; do
|
||||
echo Checking DB: $?
|
||||
sleep 3
|
||||
done
|
||||
echo "Connection established"
|
||||
}
|
||||
|
||||
function handleStartup() {
|
||||
# set mem limits and copy in custom logger config
|
||||
sed -i "s/memory_limit.*/memory_limit=$memory_limit/g" /usr/local/etc/php/php.ini
|
||||
cp /assets/monolog.yaml /opt/kimai/config/packages/monolog.yaml
|
||||
|
||||
tar -zx -C /opt/kimai -f /var/tmp/public.tgz
|
||||
|
||||
if [ -z "$USER_ID" ]; then
|
||||
USER_ID=$(id -u www-data)
|
||||
fi
|
||||
if [ -z "$GROUP_ID" ]; then
|
||||
GROUP_ID=$(id -g www-data)
|
||||
fi
|
||||
|
||||
# if group doesn't exist
|
||||
if grep -w "$GROUP_ID" /etc/group &>/dev/null; then
|
||||
echo Group already exists
|
||||
else
|
||||
echo www-kimai:x:"$GROUP_ID": >> /etc/group
|
||||
grpconv
|
||||
fi
|
||||
|
||||
# if user doesn't exist
|
||||
if id "$USER_ID" &>/dev/null; then
|
||||
echo User already exists
|
||||
else
|
||||
echo www-kimai:x:"$USER_ID":"$GROUP_ID":www-kimai:/var/www:/usr/sbin/nologin >> /etc/passwd
|
||||
pwconv
|
||||
fi
|
||||
|
||||
if [ -e /use_apache ]; then
|
||||
export APACHE_RUN_USER=$(id -nu "$USER_ID")
|
||||
# This doesn't _exactly_ run as the specified GID, it runs as the GID of the specified user but WTF
|
||||
export APACHE_RUN_GROUP=$(id -ng "$USER_ID")
|
||||
export APACHE_PID_FILE=/var/run/apache2/apache2.pid
|
||||
export APACHE_RUN_DIR=/var/run/apache2
|
||||
export APACHE_LOCK_DIR=/var/lock/apache2
|
||||
export APACHE_LOG_DIR=/var/log/apache2
|
||||
export LANG=C
|
||||
elif [ -e /use_fpm ]; then
|
||||
sed -i "s/user = .*/user = $USER_ID/g" /usr/local/etc/php-fpm.d/www.conf
|
||||
sed -i "s/group = .*/group = $GROUP_ID/g" /usr/local/etc/php-fpm.d/www.conf
|
||||
echo "Setting fpm to run as ${USER_ID}:${GROUP_ID}"
|
||||
else
|
||||
echo "Error, unknown server type"
|
||||
fi
|
||||
}
|
||||
|
||||
config
|
||||
handleStartup
|
||||
|
||||
exec /service.sh
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/sh -e
|
||||
|
||||
# Test PHP/Kimai
|
||||
/opt/kimai/bin/console kimai:version
|
||||
if [ $? != 0 ]; then
|
||||
echo "PHP/Kimai not responding"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test FPM CGI
|
||||
if [ -f /use_fpm ]; then
|
||||
echo Testing FPM
|
||||
php-fpm -t
|
||||
fi
|
||||
|
||||
# Test Apache/httpd
|
||||
if [ -f /use_apache ]; then
|
||||
echo Testing Apache
|
||||
apache2ctl -t
|
||||
fi
|
||||
|
||||
exit 0
|
||||
7
.github/ISSUE_TEMPLATE/config.yml
vendored
7
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -3,9 +3,12 @@ contact_links:
|
||||
- name: Read our documentation
|
||||
url: https://www.kimai.org/documentation/
|
||||
about: Save your time! There is an instant solution for many issues in the documentation.
|
||||
- name: Docker image issue tracker
|
||||
url: https://github.com/tobybatch/kimai2/issues
|
||||
about: For issues specific for Kimai Docker deployment
|
||||
- name: Ask a Question
|
||||
url: https://github.com/kimai/kimai/discussions
|
||||
about: Want to discuss something with a community? Do it in discussions!
|
||||
- name: Get Professional support
|
||||
url: https://www.kimai.org/en/support.html
|
||||
about: As a customer, you will always receive fast replies from the developer.
|
||||
url: https://www.kimai.org/store/custom-plugins.html
|
||||
about: As a customer, you will always receive fast and helpful replies from the developer.
|
||||
|
||||
40
.github/ISSUE_TEMPLATE/docker.yml
vendored
40
.github/ISSUE_TEMPLATE/docker.yml
vendored
@@ -1,40 +0,0 @@
|
||||
name: Docker issue
|
||||
description: Experiencing an issue with your Docker Setup?
|
||||
labels: [ "docker" ]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for reporting an issue with your docker setup! This form will guide you to create a useful issue report.
|
||||
- type: textarea
|
||||
id: describe
|
||||
attributes:
|
||||
label: Describe the problem
|
||||
description: >
|
||||
Please provide a clear and concise description of what the issue is and the steps to reproduce the behaviour:
|
||||
1. Start the container '...'
|
||||
2. Click on '....'
|
||||
3. See error xyz
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Describe your setup and add your Docker compose file (redact your credentials)
|
||||
description: >
|
||||
Your working environment:
|
||||
- OS: [e.g. Linux, Windows, Mac]
|
||||
- Docker version: [e.g. 19.03.2]
|
||||
- Docker compose version: [e.g. 1.21.0]
|
||||
|
||||
Docker compose file:
|
||||
version: '3.5'
|
||||
services:
|
||||
image: ...
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: command
|
||||
attributes:
|
||||
label: Command used to run the container
|
||||
description: e.g. docker run -v ....
|
||||
115
.github/workflows/docker.yaml
vendored
115
.github/workflows/docker.yaml
vendored
@@ -1,107 +1,16 @@
|
||||
name: 'Docker Build'
|
||||
# act -j build -W ./.github/workflows/docker.yaml --secret-file .dockerhub.secrets
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [released]
|
||||
release:
|
||||
types: [released]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
type: [ dev, prod ]
|
||||
server: [ fpm, apache ]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Install lastversion
|
||||
run: sudo apt-get update -y; sudo apt-get install --no-install-recommends -y python3 python3-pip; pip install lastversion
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{secrets.DOCKERHUB_USERNAME}}
|
||||
password: ${{secrets.DOCKERHUB_PASSWORD}}
|
||||
|
||||
- name: get lastest version
|
||||
id: remote_version
|
||||
run: echo "::set-output name=version::$(lastversion https://github.com/kimai/kimai)"
|
||||
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
KIMAI=${{steps.remote_version.outputs.version}}
|
||||
TIMEZONE=Europe/London
|
||||
BASE=${{ matrix.server }}
|
||||
target: ${{ matrix.type }}
|
||||
platforms: linux/amd64,linux/arm64 #,linux/arm/v8,linux/arm/v7,linux/arm/v6
|
||||
tags: |
|
||||
kimai/kimai2:${{ matrix.server }}-${{ matrix.type }}
|
||||
kimai/kimai2:${{ matrix.server }}-${{steps.remote_version.outputs.version}}-${{ matrix.type }}
|
||||
push: true
|
||||
|
||||
- name: Test Lite
|
||||
run: docker run --rm --entrypoint /assets/test-lite.sh kimai/kimai2:${{ matrix.server }}-${{ matrix.type }}
|
||||
|
||||
tag:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{secrets.DOCKERHUB_USERNAME}}
|
||||
password: ${{secrets.DOCKERHUB_PASSWORD}}
|
||||
|
||||
- name: Pull images
|
||||
run: |
|
||||
docker pull kimai/kimai2:fpm-prod
|
||||
docker pull kimai/kimai2:fpm-dev
|
||||
docker pull kimai/kimai2:apache-prod
|
||||
docker pull kimai/kimai2:apache-dev
|
||||
|
||||
- name: Tag fpm
|
||||
run: |
|
||||
docker tag kimai/kimai2:fpm-prod kimai/kimai2:fpm
|
||||
docker push kimai/kimai2:fpm
|
||||
|
||||
- name: Tag fpm latest
|
||||
run: |
|
||||
docker tag kimai/kimai2:fpm-prod kimai/kimai2:fpm-latest
|
||||
docker push kimai/kimai2:fpm-latest
|
||||
|
||||
- name: Tag latest
|
||||
run: |
|
||||
docker tag kimai/kimai2:fpm-prod kimai/kimai2:latest
|
||||
docker push kimai/kimai2:latest
|
||||
|
||||
- name: Tag prod
|
||||
run: |
|
||||
docker tag kimai/kimai2:fpm-prod kimai/kimai2:prod
|
||||
docker push kimai/kimai2:prod
|
||||
|
||||
- name: Tag apache
|
||||
run: |
|
||||
docker tag kimai/kimai2:apache-prod kimai/kimai2:apache
|
||||
docker push kimai/kimai2:apache
|
||||
|
||||
- name: Tag apache latest
|
||||
run: |
|
||||
docker tag kimai/kimai2:apache-prod kimai/kimai2:apache-latest
|
||||
docker push kimai/kimai2:apache-latest
|
||||
|
||||
- name: Tag dev
|
||||
run: |
|
||||
docker tag kimai/kimai2:apache-dev kimai/kimai2:dev
|
||||
docker push kimai/kimai2:dev
|
||||
build:
|
||||
name: Trigger docker image build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Emit repository_dispatch
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||
repository: tobybatch/kimai2
|
||||
event-type: kimai_release
|
||||
|
||||
4
.github/workflows/testing.yaml
vendored
4
.github/workflows/testing.yaml
vendored
@@ -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']
|
||||
php: ['8.1', '8.2']
|
||||
|
||||
name: Integration (${{ matrix.php }})
|
||||
steps:
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
TEST_WITH_BUNDLES: 1
|
||||
|
||||
- name: Upload code coverage
|
||||
if: matrix.php == '8.2'
|
||||
if: matrix.php == '8.1'
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -2,7 +2,7 @@
|
||||
/public/.htaccess
|
||||
/.env-*
|
||||
/.idea/
|
||||
.DS_Store
|
||||
/.DS_Store
|
||||
/phpstan.sh
|
||||
|
||||
# custom apache rules e.g. to deactivate ioncube loader
|
||||
@@ -22,11 +22,12 @@
|
||||
/var/data/*
|
||||
/var/cache/*
|
||||
/var/invoices*
|
||||
/var/export*
|
||||
/var/export/*
|
||||
/var/log/*
|
||||
/var/sessions/*
|
||||
/var/packages/*
|
||||
/var/plugins*
|
||||
/var/plugins/*
|
||||
/var/plugins_old/
|
||||
/var/plugins/*/*.disabled
|
||||
|
||||
###> symfony/framework-bundle ###
|
||||
|
||||
@@ -60,7 +60,7 @@ $fixer
|
||||
'lowercase_static_reference' => true,
|
||||
'magic_constant_casing' => true,
|
||||
'native_function_casing' => true,
|
||||
'new_with_parentheses' => true,
|
||||
'new_with_braces' => true,
|
||||
'no_blank_lines_after_class_opening' => true,
|
||||
'no_blank_lines_after_phpdoc' => true,
|
||||
'no_empty_comment' => true,
|
||||
@@ -82,7 +82,7 @@ $fixer
|
||||
'no_singleline_whitespace_before_semicolons' => true,
|
||||
'no_spaces_around_offset' => true,
|
||||
'no_trailing_comma_in_singleline' => true,
|
||||
'no_unneeded_braces' => true,
|
||||
'no_unneeded_curly_braces' => true,
|
||||
'no_unneeded_final_method' => true,
|
||||
'no_unused_imports' => true,
|
||||
'no_whitespace_before_comma_in_array' => true,
|
||||
@@ -151,7 +151,7 @@ $fixer
|
||||
],
|
||||
'scope' => 'namespaced'
|
||||
],
|
||||
'native_type_declaration_casing' => true,
|
||||
'native_function_type_declaration_casing' => true,
|
||||
'no_alias_functions' => [
|
||||
'sets' => [
|
||||
'@internal'
|
||||
@@ -170,7 +170,6 @@ $fixer
|
||||
->setFinder(
|
||||
PhpCsFixer\Finder::create()
|
||||
->in([
|
||||
__DIR__ . '/migrations/',
|
||||
__DIR__ . '/src/',
|
||||
__DIR__ . '/tests/',
|
||||
])
|
||||
|
||||
322
Dockerfile
322
Dockerfile
@@ -1,322 +0,0 @@
|
||||
# _ ___ _
|
||||
# | |/ (_)_ __ ___ __ _(_)
|
||||
# | ' /| | '_ ` _ \ / _` | |
|
||||
# | . \| | | | | | | (_| | |
|
||||
# |_|\_\_|_| |_| |_|\__,_|_|
|
||||
#
|
||||
|
||||
# Source base [fpm/apache]
|
||||
ARG BASE="fpm"
|
||||
ARG PHP_VER="8.2"
|
||||
ARG COMPOSER_VER="latest"
|
||||
# Branch name
|
||||
ARG KIMAI="main"
|
||||
# Timezone for images
|
||||
ARG TIMEZONE="Europe/Berlin"
|
||||
|
||||
###########################
|
||||
# Shared tools
|
||||
###########################
|
||||
|
||||
# full kimai source
|
||||
FROM alpine:latest AS git-dev
|
||||
# pass-through Arguments in every stage. See: https://benkyriakou.com/posts/docker-args-empty
|
||||
ARG KIMAI
|
||||
ARG TIMEZONE
|
||||
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
|
||||
|
||||
# composer base image
|
||||
FROM composer:${COMPOSER_VER} AS composer
|
||||
|
||||
###########################
|
||||
# PHP extensions
|
||||
###########################
|
||||
|
||||
#fpm alpine php extension base
|
||||
FROM php:${PHP_VER}-fpm-alpine AS fpm-php-ext-base
|
||||
RUN apk add --no-cache \
|
||||
# build-tools
|
||||
autoconf \
|
||||
dpkg \
|
||||
dpkg-dev \
|
||||
file \
|
||||
g++ \
|
||||
gcc \
|
||||
icu-dev \
|
||||
libatomic \
|
||||
libc-dev \
|
||||
libgomp \
|
||||
libmagic \
|
||||
m4 \
|
||||
make \
|
||||
mpc1 \
|
||||
mpfr4 \
|
||||
musl-dev \
|
||||
perl \
|
||||
re2c \
|
||||
# gd
|
||||
freetype-dev \
|
||||
libpng-dev \
|
||||
# icu
|
||||
icu-dev \
|
||||
icu-data-full \
|
||||
# ldap
|
||||
openldap-dev \
|
||||
libldap \
|
||||
# zip
|
||||
libzip-dev \
|
||||
# xsl
|
||||
libxslt-dev
|
||||
|
||||
|
||||
# apache debian php extension base
|
||||
FROM php:${PHP_VER}-apache-bookworm AS apache-php-ext-base
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y \
|
||||
libldap2-dev \
|
||||
libicu-dev \
|
||||
libpng-dev \
|
||||
libzip-dev \
|
||||
libxslt1-dev \
|
||||
libfreetype6-dev
|
||||
|
||||
|
||||
# php extension gd - 13.86s
|
||||
FROM ${BASE}-php-ext-base AS php-ext-gd
|
||||
RUN docker-php-ext-configure gd \
|
||||
--with-freetype && \
|
||||
docker-php-ext-install -j$(nproc) gd
|
||||
|
||||
# php extension intl : 15.26s
|
||||
FROM ${BASE}-php-ext-base AS php-ext-intl
|
||||
RUN docker-php-ext-install -j$(nproc) intl
|
||||
|
||||
# php extension ldap : 8.45s
|
||||
FROM ${BASE}-php-ext-base AS php-ext-ldap
|
||||
RUN docker-php-ext-configure ldap && \
|
||||
docker-php-ext-install -j$(nproc) ldap
|
||||
|
||||
# php extension pdo_mysql : 6.14s
|
||||
FROM ${BASE}-php-ext-base AS php-ext-pdo_mysql
|
||||
RUN docker-php-ext-install -j$(nproc) pdo_mysql
|
||||
|
||||
# php extension zip : 8.18s
|
||||
FROM ${BASE}-php-ext-base AS php-ext-zip
|
||||
RUN docker-php-ext-install -j$(nproc) zip
|
||||
|
||||
# php extension xsl : ?.?? s
|
||||
FROM ${BASE}-php-ext-base AS php-ext-xsl
|
||||
RUN docker-php-ext-install -j$(nproc) xsl
|
||||
|
||||
# php extension redis
|
||||
FROM ${BASE}-php-ext-base AS php-ext-redis
|
||||
RUN yes no | pecl install redis && \
|
||||
docker-php-ext-enable redis
|
||||
|
||||
# php extension opcache
|
||||
FROM ${BASE}-php-ext-base AS php-ext-opcache
|
||||
RUN docker-php-ext-install -j$(nproc) opcache
|
||||
|
||||
###########################
|
||||
# fpm base build
|
||||
###########################
|
||||
|
||||
# fpm base build
|
||||
FROM php:${PHP_VER}-fpm-alpine AS fpm-base
|
||||
ARG KIMAI
|
||||
ARG TIMEZONE
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
coreutils \
|
||||
freetype \
|
||||
haveged \
|
||||
icu \
|
||||
icu-data-full \
|
||||
libldap \
|
||||
libpng \
|
||||
libzip \
|
||||
libxslt-dev \
|
||||
fcgi \
|
||||
tzdata && \
|
||||
touch /use_fpm
|
||||
|
||||
EXPOSE 9000
|
||||
|
||||
HEALTHCHECK --interval=20s --timeout=10s --retries=3 \
|
||||
CMD \
|
||||
SCRIPT_NAME=/ping \
|
||||
SCRIPT_FILENAME=/ping \
|
||||
REQUEST_METHOD=GET \
|
||||
cgi-fcgi -bind -connect 127.0.0.1:9000 || exit 1
|
||||
|
||||
|
||||
|
||||
###########################
|
||||
# apache base build
|
||||
###########################
|
||||
|
||||
FROM php:${PHP_VER}-apache-bookworm AS apache-base
|
||||
ARG KIMAI
|
||||
ARG TIMEZONE
|
||||
COPY .docker/000-default.conf /etc/apache2/sites-available/000-default.conf
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
bash \
|
||||
haveged \
|
||||
libicu72 \
|
||||
libpng16-16 \
|
||||
libzip4 \
|
||||
libxslt1.1 \
|
||||
libfreetype6 && \
|
||||
echo "Listen 8001" > /etc/apache2/ports.conf && \
|
||||
a2enmod rewrite && \
|
||||
touch /use_apache
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
HEALTHCHECK --interval=20s --timeout=10s --retries=3 \
|
||||
CMD curl -f http://127.0.0.1:8001 || exit 1
|
||||
|
||||
|
||||
|
||||
###########################
|
||||
# global base build
|
||||
###########################
|
||||
|
||||
FROM ${BASE}-base AS base
|
||||
ARG KIMAI
|
||||
ARG TIMEZONE
|
||||
|
||||
LABEL maintainer="tobias@neontribe.co.uk"
|
||||
LABEL maintainer="bastian@schroll-software.de"
|
||||
|
||||
ENV KIMAI=${KIMAI}
|
||||
ENV TIMEZONE=${TIMEZONE}
|
||||
RUN ln -snf /usr/share/zoneinfo/${TIMEZONE} /etc/localtime && echo ${TIMEZONE} > /etc/timezone && \
|
||||
# make composer home dir
|
||||
mkdir /composer && \
|
||||
chown -R www-data:www-data /composer
|
||||
|
||||
# copy startup script & DB checking script
|
||||
COPY .docker/startup.sh /startup.sh
|
||||
COPY .docker/service.sh /service.sh
|
||||
COPY .docker/self-test.sh /self-test.sh
|
||||
COPY .docker/dbtest.php /dbtest.php
|
||||
|
||||
# copy composer
|
||||
COPY --from=composer /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# copy php extensions
|
||||
|
||||
# PHP extension xsl
|
||||
COPY --from=php-ext-xsl /usr/local/etc/php/conf.d/docker-php-ext-xsl.ini /usr/local/etc/php/conf.d/docker-php-ext-xsl.ini
|
||||
COPY --from=php-ext-xsl /usr/local/lib/php/extensions/no-debug-non-zts-20220829/xsl.so /usr/local/lib/php/extensions/no-debug-non-zts-20220829/xsl.so
|
||||
# PHP extension pdo_mysql
|
||||
COPY --from=php-ext-pdo_mysql /usr/local/etc/php/conf.d/docker-php-ext-pdo_mysql.ini /usr/local/etc/php/conf.d/docker-php-ext-pdo_mysql.ini
|
||||
COPY --from=php-ext-pdo_mysql /usr/local/lib/php/extensions/no-debug-non-zts-20220829/pdo_mysql.so /usr/local/lib/php/extensions/no-debug-non-zts-20220829/pdo_mysql.so
|
||||
# PHP extension zip
|
||||
COPY --from=php-ext-zip /usr/local/etc/php/conf.d/docker-php-ext-zip.ini /usr/local/etc/php/conf.d/docker-php-ext-zip.ini
|
||||
COPY --from=php-ext-zip /usr/local/lib/php/extensions/no-debug-non-zts-20220829/zip.so /usr/local/lib/php/extensions/no-debug-non-zts-20220829/zip.so
|
||||
# PHP extension ldap
|
||||
COPY --from=php-ext-ldap /usr/local/etc/php/conf.d/docker-php-ext-ldap.ini /usr/local/etc/php/conf.d/docker-php-ext-ldap.ini
|
||||
COPY --from=php-ext-ldap /usr/local/lib/php/extensions/no-debug-non-zts-20220829/ldap.so /usr/local/lib/php/extensions/no-debug-non-zts-20220829/ldap.so
|
||||
# PHP extension gd
|
||||
COPY --from=php-ext-gd /usr/local/etc/php/conf.d/docker-php-ext-gd.ini /usr/local/etc/php/conf.d/docker-php-ext-gd.ini
|
||||
COPY --from=php-ext-gd /usr/local/lib/php/extensions/no-debug-non-zts-20220829/gd.so /usr/local/lib/php/extensions/no-debug-non-zts-20220829/gd.so
|
||||
# PHP extension intl
|
||||
COPY --from=php-ext-intl /usr/local/etc/php/conf.d/docker-php-ext-intl.ini /usr/local/etc/php/conf.d/docker-php-ext-intl.ini
|
||||
COPY --from=php-ext-intl /usr/local/lib/php/extensions/no-debug-non-zts-20220829/intl.so /usr/local/lib/php/extensions/no-debug-non-zts-20220829/intl.so
|
||||
# PHP extension redis
|
||||
COPY --from=php-ext-redis /usr/local/etc/php/conf.d/docker-php-ext-redis.ini /usr/local/etc/php/conf.d/docker-php-ext-redis.ini
|
||||
COPY --from=php-ext-redis /usr/local/lib/php/extensions/no-debug-non-zts-20220829/redis.so /usr/local/lib/php/extensions/no-debug-non-zts-20220829/redis.so
|
||||
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
|
||||
|
||||
ENV DATABASE_URL="mysql://kimai:kimai@127.0.0.1:3306/kimai?charset=utf8mb4&serverVersion=5.7.40"
|
||||
ENV APP_SECRET=change_this_to_something_unique
|
||||
# The default container name for nginx is nginx
|
||||
ENV TRUSTED_PROXIES=nginx,localhost,127.0.0.1
|
||||
ENV TRUSTED_HOSTS=nginx,localhost,127.0.0.1
|
||||
ENV MAILER_FROM=kimai@example.com
|
||||
ENV MAILER_URL=null://localhost
|
||||
ENV ADMINPASS=
|
||||
ENV ADMINMAIL=
|
||||
ENV DB_TYPE=
|
||||
ENV DB_USER=
|
||||
ENV DB_PASS=
|
||||
ENV DB_HOST=
|
||||
ENV DB_PORT=
|
||||
ENV DB_BASE=
|
||||
ENV COMPOSER_MEMORY_LIMIT=-1
|
||||
# If this set then the image will start, run a self test and then exit. It's used for the release process
|
||||
ENV TEST_AND_EXIT=
|
||||
ENV COMPOSER_ALLOW_SUPERUSER=1
|
||||
ENV USER_ID=
|
||||
ENV GROUP_ID=
|
||||
|
||||
VOLUME [ "/opt/kimai/var" ]
|
||||
|
||||
CMD [ "/startup.sh" ]
|
||||
|
||||
|
||||
|
||||
###########################
|
||||
# final builds
|
||||
###########################
|
||||
|
||||
# developement build
|
||||
FROM base AS dev
|
||||
# copy kimai develop source
|
||||
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 && \
|
||||
composer --no-ansi clearcache && \
|
||||
composer --no-ansi require --working-dir=/opt/kimai laminas/laminas-ldap && \
|
||||
cp /usr/local/etc/php/php.ini-development /usr/local/etc/php/php.ini && \
|
||||
chown -R www-data:www-data /opt/kimai /usr/local/etc/php/php.ini && \
|
||||
mkdir -p /opt/kimai/var/logs && chmod 777 /opt/kimai/var/logs && \
|
||||
sed "s/128M/-1/g" /usr/local/etc/php/php.ini-development > /opt/kimai/php-cli.ini && \
|
||||
sed -i "s/env php/env -S php -c \/opt\/kimai\/php-cli.ini/g" /opt/kimai/bin/console && \
|
||||
tar -C /opt/kimai -zcvf /var/tmp/public.tgz public && \
|
||||
/opt/kimai/bin/console kimai:version | awk '{print $2}' > /opt/kimai/version.txt
|
||||
ENV APP_ENV=dev
|
||||
ENV DATABASE_URL=
|
||||
ENV memory_limit=256M
|
||||
|
||||
# production build
|
||||
FROM base AS prod
|
||||
# copy kimai production source
|
||||
COPY --from=git-prod --chown=www-data:www-data /opt/kimai /opt/kimai
|
||||
COPY .docker /assets
|
||||
# do the composer deps installation
|
||||
RUN \
|
||||
export COMPOSER_HOME=/composer && \
|
||||
composer --no-ansi install --working-dir=/opt/kimai --no-dev --optimize-autoloader && \
|
||||
composer --no-ansi clearcache && \
|
||||
composer --no-ansi require --working-dir=/opt/kimai laminas/laminas-ldap && \
|
||||
cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini && \
|
||||
sed -i "s/expose_php = On/expose_php = Off/g" /usr/local/etc/php/php.ini && \
|
||||
sed -i "s/;opcache.enable=1/opcache.enable=1/g" /usr/local/etc/php/php.ini && \
|
||||
sed -i "s/;opcache.memory_consumption=128/opcache.memory_consumption=256/g" /usr/local/etc/php/php.ini && \
|
||||
sed -i "s/;opcache.interned_strings_buffer=8/opcache.interned_strings_buffer=24/g" /usr/local/etc/php/php.ini && \
|
||||
sed -i "s/;opcache.max_accelerated_files=10000/opcache.max_accelerated_files=100000/g" /usr/local/etc/php/php.ini && \
|
||||
sed -i "s/opcache.validate_timestamps=1/opcache.validate_timestamps=0/g" /usr/local/etc/php/php.ini && \
|
||||
sed -i "s/session.gc_maxlifetime = 1440/session.gc_maxlifetime = 604800/g" /usr/local/etc/php/php.ini && \
|
||||
mkdir -p /opt/kimai/var/logs && chmod 777 /opt/kimai/var/logs && \
|
||||
sed "s/128M/-1/g" /usr/local/etc/php/php.ini-development > /opt/kimai/php-cli.ini && \
|
||||
chown -R www-data:www-data /opt/kimai /usr/local/etc/php/php.ini && \
|
||||
tar -C /opt/kimai -zcvf /var/tmp/public.tgz public && \
|
||||
/opt/kimai/bin/console kimai:version | awk '{print $2}' > /opt/kimai/version.txt
|
||||
ENV APP_ENV=prod
|
||||
ENV DATABASE_URL=
|
||||
ENV memory_limit=256M
|
||||
@@ -38,13 +38,11 @@ Do **NOT** use Version 1, it won't get any more updates!
|
||||
|
||||
### Requirements
|
||||
|
||||
- PHP 8.1.3 minimum
|
||||
- PHP 8.1 minimum
|
||||
- MariaDB or MySQL
|
||||
- A webserver and subdomain (subdirectory is not supported)
|
||||
- PHP extensions: `gd`, `intl`, `json`, `mbstring`, `pdo`, `tokenizer`, `xml`, `xsl`, `zip`
|
||||
|
||||
Support for PHP 8.2 and 8.3.
|
||||
|
||||
## Installation
|
||||
|
||||
- [Recommended setup](https://www.kimai.org/documentation/installation.html#recommended-setup) — with Git and Composer
|
||||
|
||||
@@ -57,16 +57,6 @@ export default class KimaiDatePicker extends KimaiFormPlugin {
|
||||
if (element.dataset.format === undefined) {
|
||||
console.log('Trying to bind litepicker to an element without data-format attribute');
|
||||
}
|
||||
if (element.hasAttribute('min') !== undefined) {
|
||||
options = {...options, ...{
|
||||
'minDate': element.getAttribute('min'),
|
||||
}};
|
||||
}
|
||||
if (element.hasAttribute('max') !== undefined) {
|
||||
options = {...options, ...{
|
||||
'maxDate': element.getAttribute('max'),
|
||||
}};
|
||||
}
|
||||
options = {...options, ...{
|
||||
format: element.dataset.format,
|
||||
showTooltip: false,
|
||||
@@ -76,7 +66,7 @@ export default class KimaiDatePicker extends KimaiFormPlugin {
|
||||
firstDay: FIRST_DOW, // Litepicker: 0 = Sunday, 1 = Monday
|
||||
setup: (picker) => {
|
||||
// nasty hack, because litepicker does not trigger change event on the input and the available
|
||||
// event "selected" is triggered way to often, even when moving the cursor inside the input
|
||||
// event "selected" is triggered why to often, even when moving the cursor inside the input
|
||||
// element (not even typing is necessary) and so we have to make sure that the manual "click" event
|
||||
// (works for touch as well) happened before we actually dispatch the change event manually ...
|
||||
// what? report forms would be submitted upon cursor move without the "preselect” check
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* [KIMAI] KimaiThemeInitializer: initialize theme functionality
|
||||
*/
|
||||
|
||||
import { Tooltip, Offcanvas } from 'bootstrap';
|
||||
import { Tooltip } from 'bootstrap';
|
||||
import KimaiPlugin from '../KimaiPlugin';
|
||||
|
||||
export default class KimaiThemeInitializer extends KimaiPlugin {
|
||||
@@ -21,10 +21,6 @@ export default class KimaiThemeInitializer extends KimaiPlugin {
|
||||
return new Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
|
||||
// support for offcanvas elements
|
||||
const offcanvasElementList = document.querySelectorAll('.offcanvas');
|
||||
[...offcanvasElementList].map(offcanvasEl => new Offcanvas(offcanvasEl));
|
||||
|
||||
// activate all form plugins
|
||||
/** @type {KimaiForm} FORMS */
|
||||
const FORMS = this.getContainer().getPlugin('form');
|
||||
|
||||
@@ -43,21 +43,3 @@ h1.navbar-brand a {
|
||||
.navbar {
|
||||
--tblr-navbar-brand-font-size: 1rem;
|
||||
}
|
||||
|
||||
/* user shortcuts dropdown */
|
||||
.user-shortcuts {
|
||||
.dropdown-menu-card {
|
||||
min-width: 260px;
|
||||
}
|
||||
.user-shortcuts-icon {
|
||||
background-color: var(--tblr-bg-surface-secondary);
|
||||
color: var(--tblr-gray-500);
|
||||
height: 3.125rem;
|
||||
width: 3.125rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ table.dataTable {
|
||||
thead .sorting_asc:after,
|
||||
thead .sorting_desc:after {
|
||||
padding-left: 5px;
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
opacity: 0.5;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "8.1.*||8.2.*||8.3.*",
|
||||
"php": "8.1.*||8.2.*",
|
||||
"ext-gd": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-json": "*",
|
||||
@@ -35,7 +35,7 @@
|
||||
"friendsofsymfony/rest-bundle": "^3.0",
|
||||
"gedmo/doctrine-extensions": "^3.6",
|
||||
"jms/serializer-bundle": "^5.0",
|
||||
"kevinpapst/tabler-bundle": "^1.1",
|
||||
"kevinpapst/tabler-bundle": "^1.0",
|
||||
"league/csv": "^9.4",
|
||||
"mpdf/mpdf": "^8.0",
|
||||
"nelmio/api-doc-bundle": "^4.0",
|
||||
@@ -59,7 +59,6 @@
|
||||
"symfony/http-client": "^6.0",
|
||||
"symfony/intl": "^6.0",
|
||||
"symfony/mailer": "^6.0",
|
||||
"symfony/mime": "^6.0",
|
||||
"symfony/monolog-bundle": "^3.4",
|
||||
"symfony/rate-limiter": "^6.0",
|
||||
"symfony/runtime": "^6.0",
|
||||
@@ -174,7 +173,8 @@
|
||||
"bin/console lint:container",
|
||||
"bin/console lint:yaml config --parse-tags",
|
||||
"bin/console lint:twig templates --show-deprecations",
|
||||
"bin/console lint:xliff translations"
|
||||
"bin/console lint:xliff translations",
|
||||
"bin/console doctrine:schema:validate --skip-sync -vvv --no-interaction"
|
||||
],
|
||||
"tests": "vendor/bin/phpunit tests/",
|
||||
"tests-unit": "vendor/bin/phpunit --exclude-group integration tests/",
|
||||
@@ -202,7 +202,7 @@
|
||||
"symfony": {
|
||||
"id": "01C3FWRDJJEX9K6Y3A4XDFXPBR",
|
||||
"allow-contrib": true,
|
||||
"require": "6.4.*"
|
||||
"require": "6.3.*"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2303
composer.lock
generated
2303
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -199,7 +199,7 @@
|
||||
],
|
||||
'en_CH' =>
|
||||
[
|
||||
'date' => 'dd.MM.y',
|
||||
'date' => 'dd/MM/y',
|
||||
'time' => 'HH:mm',
|
||||
'rtl' => false,
|
||||
],
|
||||
@@ -457,7 +457,7 @@
|
||||
],
|
||||
'en_MV' =>
|
||||
[
|
||||
'date' => 'd-M-yy',
|
||||
'date' => 'M/d/yy',
|
||||
'time' => 'HH:mm',
|
||||
'rtl' => false,
|
||||
],
|
||||
@@ -1316,19 +1316,19 @@
|
||||
'ko' =>
|
||||
[
|
||||
'date' => 'yy. M. d.',
|
||||
'time' => 'h:mm a',
|
||||
'time' => 'a h:mm',
|
||||
'rtl' => false,
|
||||
],
|
||||
'ko_KP' =>
|
||||
[
|
||||
'date' => 'yy. M. d.',
|
||||
'time' => 'h:mm a',
|
||||
'time' => 'a h:mm',
|
||||
'rtl' => false,
|
||||
],
|
||||
'ko_KR' =>
|
||||
[
|
||||
'date' => 'yy. M. d.',
|
||||
'time' => 'h:mm a',
|
||||
'time' => 'a h:mm',
|
||||
'rtl' => false,
|
||||
],
|
||||
'nb_NO' =>
|
||||
@@ -1589,12 +1589,6 @@
|
||||
'time' => 'HH:mm',
|
||||
'rtl' => false,
|
||||
],
|
||||
'uk' =>
|
||||
[
|
||||
'date' => 'dd.MM.yy',
|
||||
'time' => 'HH:mm',
|
||||
'rtl' => false,
|
||||
],
|
||||
'vi' =>
|
||||
[
|
||||
'date' => 'dd/MM/y',
|
||||
@@ -1613,10 +1607,4 @@
|
||||
'time' => 'HH:mm',
|
||||
'rtl' => false,
|
||||
],
|
||||
'zh_Hant' =>
|
||||
[
|
||||
'date' => 'y/M/d',
|
||||
'time' => 'Bh:mm',
|
||||
'rtl' => false,
|
||||
],
|
||||
];
|
||||
@@ -25,7 +25,6 @@ doctrine:
|
||||
orm:
|
||||
auto_generate_proxy_classes: '%kernel.debug%'
|
||||
default_entity_manager: default
|
||||
enable_lazy_ghost_objects: true
|
||||
entity_managers:
|
||||
default:
|
||||
report_fields_where_declared: true
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
framework:
|
||||
# Deprecations for 7.0
|
||||
handle_all_throwables: true
|
||||
# cannot be disabled, because of https://github.com/nelmio/NelmioApiDocBundle/pull/2164
|
||||
annotations:
|
||||
enabled: true
|
||||
# ---------------------------
|
||||
|
||||
exceptions:
|
||||
App\Validator\ValidationFailedException:
|
||||
log_level: debug
|
||||
|
||||
@@ -1,49 +1,42 @@
|
||||
monolog:
|
||||
channels: ["deprecation"]
|
||||
|
||||
when@prod:
|
||||
monolog:
|
||||
channels: ["deprecation"]
|
||||
handlers:
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: notice
|
||||
action_level: error
|
||||
handler: nested
|
||||
excluded_http_codes: [403, 404]
|
||||
channels: ["!deprecation"]
|
||||
nested:
|
||||
type: stream
|
||||
level: info
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
formatter: monolog.formatter.kimai
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine", "!deprecation"]
|
||||
# deactivated, because currently there are too many deprecations cause by gedmo and doctrine
|
||||
# deprecation:
|
||||
# type: stream
|
||||
# channels: ["deprecation"]
|
||||
# path: "%kernel.logs_dir%/deprecations.log"
|
||||
# formatter: monolog.formatter.deprecation
|
||||
|
||||
when@dev:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
channels: ["!event", "!deprecation"]
|
||||
formatter: monolog.formatter.kimai
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine", "!deprecation"]
|
||||
channels: ["!event", "!doctrine"]
|
||||
deprecation:
|
||||
type: stream
|
||||
channels: ["deprecation"]
|
||||
path: "%kernel.logs_dir%/deprecations.log"
|
||||
|
||||
when@dev:
|
||||
monolog:
|
||||
channels: ["deprecation"]
|
||||
handlers:
|
||||
main:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: info
|
||||
channels: ["!event"]
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine", "!console"]
|
||||
deprecation:
|
||||
type: stream
|
||||
channels: ["deprecation"]
|
||||
path: "%kernel.logs_dir%/deprecations.log"
|
||||
formatter: monolog.formatter.deprecation
|
||||
|
||||
when@test:
|
||||
monolog:
|
||||
@@ -53,4 +46,3 @@ when@test:
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: info
|
||||
channels: ["!event"]
|
||||
formatter: monolog.formatter.kimai
|
||||
|
||||
@@ -8,10 +8,9 @@ scheb_two_factor:
|
||||
totp:
|
||||
enabled: true
|
||||
template: security/2fa.html.twig # Overwritten template
|
||||
leeway: 29 # How many seconds the code is valid
|
||||
window: 1 # How many codes before/after the current one would be accepted as valid
|
||||
issuer: Kimai # Issuer name used in QR code
|
||||
|
||||
two_factor_condition: App\Security\TwoFactorCondition
|
||||
|
||||
# TODO add: backup codes - https://symfony.com/bundles/SchebTwoFactorBundle/current/backup_codes.html
|
||||
# TODO add: Trusted device feature - https://github.com/scheb/2fa/blob/6.x/doc/configuration.rst
|
||||
# FIXME add backup codes - https://symfony.com/bundles/SchebTwoFactorBundle/current/backup_codes.html
|
||||
|
||||
@@ -60,7 +60,6 @@ security:
|
||||
logout:
|
||||
path: logout
|
||||
target: homepage
|
||||
enable_csrf: true
|
||||
|
||||
login_throttling:
|
||||
max_attempts: 5
|
||||
|
||||
@@ -110,7 +110,7 @@ tabler:
|
||||
profile: fas fa-user-edit
|
||||
profile-stats: far fa-chart-bar
|
||||
project: fas fa-briefcase
|
||||
repeat: fas fa-repeat
|
||||
repeat: fas fa-redo-alt
|
||||
reporting: far fa-chart-bar
|
||||
report: far fa-chart-bar
|
||||
review: fas fa-user-check
|
||||
|
||||
@@ -60,6 +60,18 @@ services:
|
||||
arguments:
|
||||
$cacheDirectory: '%kernel.cache_dir%'
|
||||
|
||||
App\Plugin\PluginManager:
|
||||
arguments: [!tagged kimai.plugin]
|
||||
|
||||
App\Validator\Constraints\TimesheetValidator:
|
||||
arguments: [!tagged timesheet.validator]
|
||||
|
||||
App\Validator\Constraints\ProjectValidator:
|
||||
arguments: [!tagged project.validator]
|
||||
|
||||
App\Validator\Constraints\QuickEntryTimesheetValidator:
|
||||
arguments: [!tagged timesheet.validator]
|
||||
|
||||
App\Utils\FileHelper:
|
||||
arguments:
|
||||
$dataDir: '%kimai.data_dir%'
|
||||
@@ -71,19 +83,35 @@ services:
|
||||
arguments:
|
||||
$mailer: '@App\Mail\KimaiMailer'
|
||||
|
||||
# ================================================================================
|
||||
# DATABASE
|
||||
# ================================================================================
|
||||
|
||||
# updates timesheet records and apply configured rate & rounding rules
|
||||
App\Doctrine\TimesheetSubscriber:
|
||||
class: App\Doctrine\TimesheetSubscriber
|
||||
arguments: [!tagged timesheet.calculator]
|
||||
|
||||
# updates timestampable columns (higher priority, so the TimesheetSubscriber will be executed later)
|
||||
App\Doctrine\ModifiedSubscriber:
|
||||
class: App\Doctrine\ModifiedSubscriber
|
||||
|
||||
# ================================================================================
|
||||
# TIMESHEET RECORD CALCULATOR
|
||||
# ================================================================================
|
||||
|
||||
App\Timesheet\RoundingService:
|
||||
arguments:
|
||||
# this is currently required, as local.yaml allows to configure several rules,
|
||||
# while the database system only allows one rounding rule
|
||||
$roundingModes: !tagged timesheet.rounding_mode
|
||||
$rules: '%kimai.timesheet.rounding%'
|
||||
|
||||
App\Timesheet\RateService:
|
||||
arguments: ['%kimai.timesheet.rates%']
|
||||
|
||||
App\Timesheet\TrackingModeService:
|
||||
arguments:
|
||||
$modes: !tagged timesheet.tracking_mode
|
||||
|
||||
# ================================================================================
|
||||
# SECURITY & VOTER
|
||||
# ================================================================================
|
||||
@@ -195,15 +223,3 @@ services:
|
||||
class: App\Repository\WorkingTimeRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\WorkingTime']
|
||||
|
||||
monolog.formatter.kimai:
|
||||
class: Monolog\Formatter\LineFormatter
|
||||
arguments:
|
||||
- "[%%datetime%%] [%%extra.channel%%] %%level_name%%: %%message%% %%context%% %%extra%%\n"
|
||||
- "Y-m-d H:i:s"
|
||||
|
||||
monolog.formatter.deprecation:
|
||||
class: Monolog\Formatter\LineFormatter
|
||||
arguments:
|
||||
- "[%%datetime%%] %%message%% %%context%%\n"
|
||||
- "Y-m-d H:i:s"
|
||||
|
||||
@@ -85,7 +85,7 @@ final class Version20230126002049 extends AbstractMigration
|
||||
$this->addSql("UPDATE kimai2_configuration SET `value` = '15' WHERE `name` = 'timesheet.time_increment' and `value` = '0'");
|
||||
$this->addSql("UPDATE kimai2_configuration SET `value` = '5' WHERE `name` = 'timesheet.time_increment' and `value` IN ('1', '2', '3', '4')");
|
||||
$this->addSql("UPDATE kimai2_configuration SET `value` = '0' WHERE `name` = 'user.registration'");
|
||||
$this->addSql('UPDATE kimai2_roles SET `name` = UPPER(`name`)');
|
||||
$this->addSql("UPDATE kimai2_roles SET `name` = UPPER(`name`)");
|
||||
$this->addSql("UPDATE kimai2_invoice_templates SET `renderer` = 'service-date' WHERE `renderer` = 'freelancer'");
|
||||
$this->addSql("UPDATE kimai2_invoice_templates SET `renderer` = 'invoice' WHERE `renderer` = 'default'");
|
||||
$this->addSql("UPDATE kimai2_invoice_templates SET `renderer` = 'default' WHERE `renderer` = 'default-pdf'");
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Doctrine\AbstractMigration;
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Doctrine\AbstractMigration;
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* @version 2.5.0
|
||||
*/
|
||||
final class Version20231130000719 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Fix user preferences with dots in the name';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// select u1.* from kimai2_user_preferences u1 where u1.name like '%.%' and exists(select 1 from kimai2_user_preferences u2 where u2.name = replace(u1.name, '.', '_') and u1.user_id = u2.user_id);
|
||||
// select u1.* from kimai2_user_preferences u1 where u1.name like '%.%';
|
||||
$this->addSql("delete u1 from kimai2_user_preferences u1 where u1.name like '%.%' and exists(select 1 from (SELECT name, user_id FROM kimai2_user_preferences WHERE name LIKE '%_%') u2 where u2.name = replace(u1.name, '.', '_') and u1.user_id = u2.user_id)");
|
||||
$this->addSql("update kimai2_user_preferences set `name` = replace(`name`, '.', '_')");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
"@babel/core": "^7.19.6",
|
||||
"@babel/eslint-parser": "^7.18.2",
|
||||
"@babel/preset-env": "^7.19.4",
|
||||
"@fortawesome/fontawesome-free": "^6.5.0",
|
||||
"@fortawesome/fontawesome-free": "^6.2.0",
|
||||
"@fullcalendar/bootstrap5": "^5.11",
|
||||
"@fullcalendar/core": "^5.11",
|
||||
"@fullcalendar/daygrid": "^5.11",
|
||||
@@ -48,6 +48,7 @@
|
||||
"sass-loader": "^13.2",
|
||||
"tom-select": "^2.2",
|
||||
"webpack": "^5.74.0",
|
||||
"webpack-cli": "^4.10.0"
|
||||
"webpack-cli": "^4.10.0",
|
||||
"webpack-notifier": "^1.13"
|
||||
}
|
||||
}
|
||||
|
||||
192
phpstan.neon
192
phpstan.neon
@@ -81,6 +81,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/API/Model/PageAction.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getVersion\\(\\) on App\\\\Plugin\\\\PluginMetadata\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/API/Model/Plugin.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\API\\\\Model\\\\Plugin\\:\\:\\$name is never read, only written\\.$#"
|
||||
count: 1
|
||||
@@ -521,6 +526,16 @@ parameters:
|
||||
count: 1
|
||||
path: src/Command/InvoiceCreateCommand.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getKimaiVersion\\(\\) on App\\\\Plugin\\\\PluginMetadata\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Command/PluginCommand.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getVersion\\(\\) on App\\\\Plugin\\\\PluginMetadata\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Command/PluginCommand.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Command\\\\PromoteUserCommand\\:\\:executeRoleCommand\\(\\) has parameter \\$role with no type specified\\.$#"
|
||||
count: 1
|
||||
@@ -621,6 +636,16 @@ parameters:
|
||||
count: 1
|
||||
path: src/Command/TranslationCommand.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$original on SimpleXMLElement\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Command/TranslationCommand.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access property \\$target\\-language on SimpleXMLElement\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Command/TranslationCommand.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method asXML\\(\\) on SimpleXMLElement\\|false\\.$#"
|
||||
count: 5
|
||||
@@ -791,6 +816,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Configuration/SystemConfiguration.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Configuration\\\\SystemConfiguration\\:\\:getThemeColorChoices\\(\\) should return string\\|null but returns float\\|int\\<min, \\-1\\>\\|int\\<1, max\\>\\|string\\|true\\.$#"
|
||||
count: 1
|
||||
path: src/Configuration/SystemConfiguration.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Configuration\\\\SystemConfiguration\\:\\:getUserDefaultCurrency\\(\\) should return string but returns bool\\|float\\|int\\|string\\|null\\.$#"
|
||||
count: 1
|
||||
@@ -1111,6 +1141,11 @@ parameters:
|
||||
count: 3
|
||||
path: src/Controller/Reporting/ProjectDateRangeController.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$begin of method App\\\\Form\\\\Model\\\\DateRange\\:\\:setBegin\\(\\) expects DateTime, DateTime\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Controller/Reporting/ProjectDateRangeController.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$begin of method App\\\\Project\\\\ProjectStatisticService\\:\\:getBudgetStatisticModelForProjectsByDateRange\\(\\) expects DateTime, DateTime\\|null given\\.$#"
|
||||
count: 1
|
||||
@@ -1296,46 +1331,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Customer/CustomerStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'billable' on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Customer/CustomerStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'counter' on mixed\\.$#"
|
||||
count: 3
|
||||
path: src/Customer/CustomerStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'duration' on mixed\\.$#"
|
||||
count: 4
|
||||
path: src/Customer/CustomerStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'exported' on mixed\\.$#"
|
||||
count: 2
|
||||
path: src/Customer/CustomerStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'id' on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Customer/CustomerStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'internalRate' on mixed\\.$#"
|
||||
count: 3
|
||||
path: src/Customer/CustomerStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'rate' on mixed\\.$#"
|
||||
count: 4
|
||||
path: src/Customer/CustomerStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Customer\\\\CustomerStatisticService\\:\\:createStatisticQueryBuilder\\(\\) has parameter \\$customers with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Customer/CustomerStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Customer\\\\CustomerStatisticService\\:\\:getBudgetStatistic\\(\\) should return array\\<int, App\\\\Model\\\\CustomerStatistic\\> but returns array\\<int\\|string, App\\\\Model\\\\CustomerStatistic\\>\\.$#"
|
||||
count: 1
|
||||
@@ -1921,6 +1916,11 @@ parameters:
|
||||
count: 2
|
||||
path: src/EventSubscriber/Actions/ActivitySubscriber.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getHomepage\\(\\) on App\\\\Plugin\\\\PluginMetadata\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/EventSubscriber/Actions/PluginSubscriber.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\User\\|null\\.$#"
|
||||
count: 1
|
||||
@@ -1966,6 +1966,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Export/Base/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$file of class Symfony\\\\Component\\\\HttpFoundation\\\\BinaryFileResponse constructor expects SplFileInfo\\|string, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$id of method Symfony\\\\Contracts\\\\Translation\\\\TranslatorInterface\\:\\:trans\\(\\) expects string, string\\|null given\\.$#"
|
||||
count: 5
|
||||
@@ -2011,6 +2016,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Export/Base/HtmlRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Activity\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/HtmlRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 5
|
||||
@@ -2071,6 +2081,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Export/Base/PDFRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Activity\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/PDFRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 5
|
||||
@@ -3431,6 +3446,16 @@ parameters:
|
||||
count: 1
|
||||
path: src/Form/Type/ColorChoiceType.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Form\\\\Type\\\\ColorChoiceType\\:\\:convertStringToColorArray\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Type/ColorChoiceType.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$config of method App\\\\Form\\\\Type\\\\ColorChoiceType\\:\\:convertStringToColorArray\\(\\) expects string, string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Type/ColorChoiceType.php
|
||||
|
||||
-
|
||||
message: "#^Class App\\\\Form\\\\Type\\\\ColorPickerType implements generic interface Symfony\\\\Component\\\\Form\\\\DataTransformerInterface but does not specify its types\\: T, R$#"
|
||||
count: 1
|
||||
@@ -4026,16 +4051,31 @@ parameters:
|
||||
count: 1
|
||||
path: src/Ldap/LdapManager.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Ldap\\\\LdapManager\\:\\:hydrateRoles\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
path: src/Ldap/LdapManager.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Ldap\\\\LdapManager\\:\\:hydrateRoles\\(\\) has parameter \\$entries with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Ldap/LdapManager.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Ldap\\\\LdapManager\\:\\:hydrateUser\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
path: src/Ldap/LdapManager.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Ldap\\\\LdapManager\\:\\:hydrateUser\\(\\) has parameter \\$ldapEntry with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Ldap/LdapManager.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Ldap\\\\LdapManager\\:\\:hydrateUserWithAttributesMap\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
path: src/Ldap/LdapManager.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Ldap\\\\LdapManager\\:\\:hydrateUserWithAttributesMap\\(\\) has parameter \\$attributeMap with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -4046,6 +4086,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Ldap/LdapManager.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Ldap\\\\LdapManager\\:\\:updateUser\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
path: src/Ldap/LdapManager.php
|
||||
|
||||
-
|
||||
message: "#^PHPDoc tag @var for variable \\$attr has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -4181,14 +4226,29 @@ parameters:
|
||||
count: 1
|
||||
path: src/Plugin/PluginMetadata.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'amount' on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'billable' on mixed\\.$#"
|
||||
count: 3
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'duration' on mixed\\.$#"
|
||||
count: 9
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'exported' on mixed\\.$#"
|
||||
count: 3
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'id' on mixed\\.$#"
|
||||
count: 4
|
||||
count: 8
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
@@ -4196,6 +4256,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'rate' on mixed\\.$#"
|
||||
count: 6
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getEnd\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
|
||||
count: 2
|
||||
@@ -4261,6 +4326,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$durationTotal of method App\\\\Reporting\\\\ProjectView\\\\ProjectViewModel\\:\\:setDurationTotal\\(\\) expects int, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$durationWeek of method App\\\\Reporting\\\\ProjectView\\\\ProjectViewModel\\:\\:setDurationWeek\\(\\) expects int, mixed given\\.$#"
|
||||
count: 1
|
||||
@@ -4276,6 +4346,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$rateTotal of method App\\\\Reporting\\\\ProjectView\\\\ProjectViewModel\\:\\:setRateTotal\\(\\) expects float, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$recordDuration of method App\\\\Model\\\\TimesheetCountedStatistic\\:\\:setDurationBillable\\(\\) expects int, float\\|int given\\.$#"
|
||||
count: 1
|
||||
@@ -4291,6 +4366,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$timesheetCounter of method App\\\\Reporting\\\\ProjectView\\\\ProjectViewModel\\:\\:setTimesheetCounter\\(\\) expects int, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$totalDuration of method App\\\\Model\\\\Statistic\\\\Timesheet\\:\\:setTotalDuration\\(\\) expects int, float\\|int given\\.$#"
|
||||
count: 2
|
||||
@@ -4311,6 +4391,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Reporting/CustomerMonthlyProjects/CustomerMonthlyProjectsRepository.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\Reporting\\\\ProjectDateRange\\\\ProjectDateRangeQuery\\:\\:\\$month \\(DateTime\\) does not accept DateTime\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Reporting/ProjectDateRange/ProjectDateRangeQuery.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'project' on mixed\\.$#"
|
||||
count: 1
|
||||
@@ -4331,6 +4416,11 @@ parameters:
|
||||
count: 1
|
||||
path: src/Reporting/ProjectDetails/ProjectDetailsModel.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Reporting\\\\ProjectView\\\\ProjectViewModel\\:\\:getBudgetStatisticModel\\(\\) should return App\\\\Model\\\\BudgetStatisticModelInterface but returns App\\\\Model\\\\BudgetStatisticModelInterface\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Reporting/ProjectView/ProjectViewModel.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
|
||||
count: 1
|
||||
@@ -5041,6 +5131,11 @@ parameters:
|
||||
count: 2
|
||||
path: src/Saml/SamlProvider.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$identifier of method Symfony\\\\Component\\\\Security\\\\Core\\\\User\\\\UserProviderInterface\\:\\:loadUserByIdentifier\\(\\) expects string, string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Saml/SamlProvider.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$key of function array_key_exists expects int\\|string, mixed given\\.$#"
|
||||
count: 1
|
||||
@@ -5211,6 +5306,21 @@ parameters:
|
||||
count: 1
|
||||
path: src/Timesheet/Rounding/FloorRounding.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Timesheet\\\\RoundingService\\:\\:__construct\\(\\) has parameter \\$rules with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Timesheet/RoundingService.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Timesheet\\\\RoundingService\\:\\:getRoundingRules\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Timesheet/RoundingService.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\Timesheet\\\\RoundingService\\:\\:\\$rulesCache type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Timesheet/RoundingService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getTimezone\\(\\) on App\\\\Entity\\\\User\\|null\\.$#"
|
||||
count: 2
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
public/build/app.9acfcfc8.js
Normal file
2
public/build/app.9acfcfc8.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
public/build/app.fc41168c.css
Normal file
1
public/build/app.fc41168c.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3,10 +3,10 @@
|
||||
"app": {
|
||||
"js": [
|
||||
"/build/runtime.f0079159.js",
|
||||
"/build/app.adf0b03a.js"
|
||||
"/build/app.9acfcfc8.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app.0ecb28c1.css"
|
||||
"/build/app.fc41168c.css"
|
||||
]
|
||||
},
|
||||
"export-pdf": {
|
||||
@@ -45,7 +45,7 @@
|
||||
"calendar": {
|
||||
"js": [
|
||||
"/build/runtime.f0079159.js",
|
||||
"/build/calendar.a1cbcd7b.js"
|
||||
"/build/calendar.3f4f78fe.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/calendar.bb473428.css"
|
||||
@@ -63,8 +63,8 @@
|
||||
},
|
||||
"integrity": {
|
||||
"/build/runtime.f0079159.js": "sha384-H22sAW1aTvyIPqvHOvGXWSWTxf0y6mptp+MsVmyXCfjx/WJjBbhX9gbUZ+qIuihV",
|
||||
"/build/app.adf0b03a.js": "sha384-z0Evh/1m2s8eBABcPDKISlFOKIzUpZE5CJt7HQIVgciHgD8GxpK50XZyNCNq+gyd",
|
||||
"/build/app.0ecb28c1.css": "sha384-iCao48T4VAR0rv39D1+kCk8GCRtve2QDTH5gGq1I1TMXmy1xA/OPxjeBFgGKkRk+",
|
||||
"/build/app.9acfcfc8.js": "sha384-tzbF0uEmHghIo+D5IuKarLYkwB3AyrLMTEFqR5mqaXCP5VSOWx3t+ZfD81kd80T+",
|
||||
"/build/app.fc41168c.css": "sha384-3Td9W7KogJoBiywzWOgpkPPNHEIT8/a/SkMZM5NgIRmuChj/3PeOKkOHVJQlhP2E",
|
||||
"/build/export-pdf.d367a32e.js": "sha384-Z5baqnzjI636nYFs4g63ViIKBZKRW4Jhv/7PQmTEQlqhfA7eK0vUMUtiyy0R5A9u",
|
||||
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
|
||||
"/build/invoice.2604495e.js": "sha384-D6JvhGSqlx7z72b/qD3nF3QDXPy+XsCSRGtWfs1icjDKOcd2UzuXwuSa/E1Fg2TJ",
|
||||
@@ -72,7 +72,7 @@
|
||||
"/build/invoice-pdf.ce9aace0.js": "sha384-mtZkXrpsXFMX2YvmmTlCm91QgsTlZLzLJnDAFcTuBDtDZ7TNkqLYkfCeLzFLVfwb",
|
||||
"/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
|
||||
"/build/chart.f5becfac.js": "sha384-GSqETm8wULiVXyizvwRompfwu63r/C0Qd/AvrHDE4cqAKiIGCssb3QyBtGu1WN+W",
|
||||
"/build/calendar.a1cbcd7b.js": "sha384-rDvqiEk91ZGY5lHb7vYIUWM2M550H6JAOLt1c3KO5H+toq7Ico7d/KoEDeG52cVW",
|
||||
"/build/calendar.3f4f78fe.js": "sha384-RD8enOf0Foe7IhSOGnv2DCes4QUCvLjlONH0HFq3RK9l/ozrFZakTX6SWwKOoEda",
|
||||
"/build/calendar.bb473428.css": "sha384-900W9o8666Qpw21rLP7hn5Ql8tWd40k0IcE3QpFm7E7V6bKva7xQgGlyzVuuh4GK",
|
||||
"/build/dashboard.6774a712.js": "sha384-lBwkNqUPv+IBbznagiFakY2BOIueq/Bg89wHO2XeM9YAZ51rPkrvdMd+XmFupHsM",
|
||||
"/build/dashboard.18f5a8b7.css": "sha384-PBD8ftb2yBoSjRe2sN5Xb4dEz045kOEUfcIufdSis+tWoWdAx5j4Yic+F/6CYbrP"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"build/app.css": "/build/app.0ecb28c1.css",
|
||||
"build/app.js": "/build/app.adf0b03a.js",
|
||||
"build/app.css": "/build/app.fc41168c.css",
|
||||
"build/app.js": "/build/app.9acfcfc8.js",
|
||||
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
|
||||
"build/export-pdf.js": "/build/export-pdf.d367a32e.js",
|
||||
"build/invoice.css": "/build/invoice.5bea118e.css",
|
||||
@@ -9,7 +9,7 @@
|
||||
"build/invoice-pdf.js": "/build/invoice-pdf.ce9aace0.js",
|
||||
"build/chart.js": "/build/chart.f5becfac.js",
|
||||
"build/calendar.css": "/build/calendar.bb473428.css",
|
||||
"build/calendar.js": "/build/calendar.a1cbcd7b.js",
|
||||
"build/calendar.js": "/build/calendar.3f4f78fe.js",
|
||||
"build/dashboard.css": "/build/dashboard.18f5a8b7.css",
|
||||
"build/dashboard.js": "/build/dashboard.6774a712.js",
|
||||
"build/runtime.js": "/build/runtime.f0079159.js"
|
||||
|
||||
@@ -38,7 +38,7 @@ final class SessionAuthenticator extends AbstractAuthenticator
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function supports(Request $request): bool
|
||||
public function supports(Request $request): ?bool
|
||||
{
|
||||
if (str_contains($request->getRequestUri(), '/api/')) {
|
||||
// API docs can only be access, when the user is logged in
|
||||
@@ -62,7 +62,7 @@ final class SessionAuthenticator extends AbstractAuthenticator
|
||||
return $this->authenticator->onAuthenticationSuccess($request, $token, $firewallName);
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
||||
{
|
||||
return $this->authenticator->onAuthenticationFailure($request, $exception);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ final class TokenAuthenticator extends AbstractAuthenticator
|
||||
{
|
||||
}
|
||||
|
||||
public function supports(Request $request): bool
|
||||
public function supports(Request $request): ?bool
|
||||
{
|
||||
if (str_contains($request->getRequestUri(), '/api/')) {
|
||||
return !str_contains($request->getRequestUri(), '/api/doc');
|
||||
@@ -95,7 +95,7 @@ final class TokenAuthenticator extends AbstractAuthenticator
|
||||
return null;
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
||||
{
|
||||
$data = [
|
||||
'message' => $exception instanceof CustomUserMessageAuthenticationException ? $exception->getMessage() : 'Invalid credentials'
|
||||
|
||||
@@ -66,6 +66,7 @@ final class StatusController extends BaseApiController
|
||||
{
|
||||
$plugins = [];
|
||||
foreach ($pluginManager->getPlugins() as $plugin) {
|
||||
$pluginManager->loadMetadata($plugin);
|
||||
$plugins[] = new Plugin($plugin);
|
||||
}
|
||||
|
||||
|
||||
@@ -207,7 +207,6 @@ final class UserController extends BaseApiController
|
||||
'include_active_flag' => ($profile->getId() !== $this->getUser()->getId()),
|
||||
'include_preferences' => $this->isGranted('preferences', $profile),
|
||||
'include_supervisor' => $this->isGranted('supervisor', $profile),
|
||||
'include_password_reset' => $this->isGranted('password', $profile),
|
||||
]);
|
||||
|
||||
$form->setData($profile);
|
||||
|
||||
@@ -62,7 +62,7 @@ final class RecentActivitiesSource implements DragAndDropSource
|
||||
return $this->entries;
|
||||
}
|
||||
|
||||
public function getBlockInclude(): string
|
||||
public function getBlockInclude(): ?string
|
||||
{
|
||||
return 'calendar/drag-drop.html.twig';
|
||||
}
|
||||
|
||||
@@ -44,14 +44,12 @@ final class TimesheetEntry implements DragAndDropEntry
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
$activity = $this->timesheet->getActivity();
|
||||
if ($activity !== null && $activity->getName() !== null) {
|
||||
return $activity->getName();
|
||||
if ($this->timesheet->getActivity() !== null && $this->timesheet->getActivity()->getName() !== null) {
|
||||
return $this->timesheet->getActivity()->getName();
|
||||
}
|
||||
|
||||
$project = $this->timesheet->getProject();
|
||||
if ($project !== null && $project->getName() !== null) {
|
||||
return $project->getName();
|
||||
if (null !== $this->timesheet->getProject() && $this->timesheet->getProject()->getName() !== null) {
|
||||
return $this->timesheet->getProject()->getName();
|
||||
}
|
||||
|
||||
return $this->timesheet->getDescription() ?? '';
|
||||
@@ -62,7 +60,7 @@ final class TimesheetEntry implements DragAndDropEntry
|
||||
return $this->color;
|
||||
}
|
||||
|
||||
public function getBlockName(): string
|
||||
public function getBlockName(): ?string
|
||||
{
|
||||
return 'dd_timesheet';
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
@@ -44,7 +43,6 @@ final class CreateUserCommand extends AbstractUserCommand
|
||||
User::DEFAULT_ROLE
|
||||
)
|
||||
->addArgument('password', InputArgument::OPTIONAL, 'Password for the new user (requested if not provided)')
|
||||
->addOption('request-password', null, InputOption::VALUE_NONE, 'The user needs to set a new password during next login')
|
||||
;
|
||||
}
|
||||
|
||||
@@ -71,10 +69,6 @@ final class CreateUserCommand extends AbstractUserCommand
|
||||
$user->setEnabled(true);
|
||||
$user->setRoles(explode(',', $role));
|
||||
|
||||
if ($input->getOption('request-password') === true) {
|
||||
$user->setRequiresPasswordReset(true);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->userService->saveNewUser($user);
|
||||
$io->success(sprintf('Success! Created user: %s', $username));
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'kimai:user:list', description: 'List all users')]
|
||||
final class ListUserCommand extends Command
|
||||
{
|
||||
public function __construct(private UserRepository $repository)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$output = new SymfonyStyle($input, $output);
|
||||
$users = $this->repository->findAll();
|
||||
|
||||
$data = [];
|
||||
foreach ($users as $user) {
|
||||
$data[] = [
|
||||
$user->getUserIdentifier(),
|
||||
$user->getEmail(),
|
||||
implode(', ', $user->getRoles()),
|
||||
$user->isEnabled() ? 'X' : '',
|
||||
$user->getPasswordRequestedAt()?->format('Y-m-d H:i:s')
|
||||
];
|
||||
}
|
||||
|
||||
$header = ['Username', 'Email', 'Roles', 'Active', 'PW Reset'];
|
||||
|
||||
$output->table($header, $data);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -48,16 +48,16 @@ final class PluginCommand extends Command
|
||||
|
||||
$rows = [];
|
||||
foreach ($plugins as $plugin) {
|
||||
$this->plugins->loadMetadata($plugin);
|
||||
$meta = $plugin->getMetadata();
|
||||
$rows[] = [
|
||||
$plugin->getId(),
|
||||
$plugin->getName(),
|
||||
$meta->getVersion(),
|
||||
$meta->getKimaiVersion(),
|
||||
$plugin->getPath(),
|
||||
];
|
||||
}
|
||||
$io->table(['Id', 'Name', 'Version', 'Requires', 'Directory'], $rows);
|
||||
$io->table(['Name', 'Version', 'Requires', 'Directory'], $rows);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
@@ -101,14 +101,6 @@ final class RegenerateLocalesCommand extends Command
|
||||
$settings['date'] = $shortDate->getPattern();
|
||||
$settings['time'] = $shortTime->getPattern();
|
||||
|
||||
// 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
|
||||
// \DateTimeImmutable::getLastErrors() => Meridian can only come after an hour has been found
|
||||
if (str_contains($settings['time'], 'a ')) {
|
||||
$settings['time'] = str_replace('a ', '', $settings['time']) . ' a';
|
||||
}
|
||||
$settings['time'] = str_replace("\u{202f}", ' ', $settings['time']);
|
||||
|
||||
// make sure that sub-locales of a RTL language are also flagged as RTL
|
||||
$rtlLocale = $locale;
|
||||
if (substr_count($rtlLocale, '_') === 1) {
|
||||
|
||||
@@ -77,7 +77,7 @@ final class UserLoginLinkCommand extends Command
|
||||
$loginLink = $loginLinkDetails->getUrl();
|
||||
|
||||
if ($input->getOption('password-reset') === true) {
|
||||
$user->markPasswordRequested();
|
||||
$user->setPasswordRequestedAt(new \DateTime());
|
||||
$user->setRequiresPasswordReset(true);
|
||||
$this->userRepository->saveUser($user);
|
||||
}
|
||||
|
||||
@@ -399,7 +399,7 @@ final class SystemConfiguration
|
||||
|
||||
public function getTimesheetTrackingMode(): string
|
||||
{
|
||||
return $this->getString('timesheet.mode', 'default');
|
||||
return (string) $this->find('timesheet.mode');
|
||||
}
|
||||
|
||||
public function isTimesheetMarkdownEnabled(): bool
|
||||
@@ -442,6 +442,19 @@ final class SystemConfiguration
|
||||
return (int) $this->find('timesheet.rounding.default.duration');
|
||||
}
|
||||
|
||||
private function getIncrement(string $key, int $fallback, int $min = 1): int
|
||||
{
|
||||
$config = $this->find($key);
|
||||
|
||||
if ($config === null || trim($config) === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$config = (int) $config;
|
||||
|
||||
return max($config, $min);
|
||||
}
|
||||
|
||||
public function getTimesheetIncrementDuration(): int
|
||||
{
|
||||
return $this->getIncrement('timesheet.duration_increment', $this->getTimesheetDefaultRoundingDuration(), 0);
|
||||
@@ -482,10 +495,10 @@ final class SystemConfiguration
|
||||
return (bool) $this->find('theme.avatar_url');
|
||||
}
|
||||
|
||||
public function getThemeColorChoices(): string
|
||||
public function getThemeColorChoices(): ?string
|
||||
{
|
||||
$config = $this->find('theme.color_choices');
|
||||
if (\is_string($config) && $config !== '') {
|
||||
if (!empty($config)) {
|
||||
return $config;
|
||||
}
|
||||
|
||||
@@ -498,36 +511,4 @@ final class SystemConfiguration
|
||||
{
|
||||
return $this->find('project.copy_teams_on_create') === true;
|
||||
}
|
||||
|
||||
// ========== Helper functions ==========
|
||||
|
||||
private function getIncrement(string $key, int $fallback, int $min = 1): int
|
||||
{
|
||||
$config = $this->find($key);
|
||||
|
||||
if ($config === null || trim($config) === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$config = (int) $config;
|
||||
|
||||
return max($config, $min);
|
||||
}
|
||||
|
||||
private function getString(string $key, string $fallback): string
|
||||
{
|
||||
$config = $this->find($key);
|
||||
|
||||
if ($config === null) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$config = (string) $config;
|
||||
|
||||
if (trim($config) === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.7.0';
|
||||
public const VERSION = '2.1.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 20700;
|
||||
public const VERSION_ID = 20100;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -31,8 +31,6 @@ use App\Form\Type\ActivityType;
|
||||
use App\Repository\ActivityRateRepository;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\TeamQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
@@ -137,22 +135,6 @@ final class ActivityController extends AbstractController
|
||||
$defaultTeam = null;
|
||||
$now = $this->getDateTimeFactory()->createDateTime();
|
||||
|
||||
$exportUrl = null;
|
||||
$invoiceUrl = null;
|
||||
$params = ['customers[]' => '', 'projects[]' => '', 'activities[]' => $activity->getId(), 'daterange' => '', 'exported' => TimesheetQuery::STATE_NOT_EXPORTED, 'billable' => true];
|
||||
if ($activity->getProject() !== null) {
|
||||
$params['projects[]'] = $activity->getProject()->getId();
|
||||
if ($activity->getProject()->getCustomer() !== null) {
|
||||
$params['customers[]'] = $activity->getProject()->getCustomer()->getId();
|
||||
}
|
||||
}
|
||||
if ($this->isGranted('create_export')) {
|
||||
$exportUrl = $this->generateUrl('export', array_merge($params, ['preview' => true]));
|
||||
}
|
||||
if ($this->isGranted('view_invoice')) {
|
||||
$invoiceUrl = $this->generateUrl('invoice', $params);
|
||||
}
|
||||
|
||||
if ($this->isGranted('edit', $activity)) {
|
||||
if ($this->isGranted('create_team')) {
|
||||
$defaultTeam = $teamRepository->findOneBy(['name' => $activity->getName()]);
|
||||
@@ -165,9 +147,7 @@ final class ActivityController extends AbstractController
|
||||
}
|
||||
|
||||
if ($this->isGranted('permissions', $activity) || $this->isGranted('details', $activity) || $this->isGranted('view_team')) {
|
||||
$query = new TeamQuery();
|
||||
$query->addActivity($activity);
|
||||
$teams = $teamRepository->getTeamsForQuery($query);
|
||||
$teams = $activity->getTeams();
|
||||
}
|
||||
|
||||
// additional boxes by plugins
|
||||
@@ -188,9 +168,7 @@ final class ActivityController extends AbstractController
|
||||
'team' => $defaultTeam,
|
||||
'teams' => $teams,
|
||||
'now' => $now,
|
||||
'boxes' => $boxes,
|
||||
'export_url' => $exportUrl,
|
||||
'invoice_url' => $invoiceUrl,
|
||||
'boxes' => $boxes
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ 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\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Http\SecurityRequestAttributes;
|
||||
|
||||
#[Route(path: '/saml')]
|
||||
final class SamlController extends AbstractController
|
||||
@@ -32,7 +32,7 @@ final class SamlController extends AbstractController
|
||||
}
|
||||
|
||||
$session = $request->getSession();
|
||||
$authErrorKey = SecurityRequestAttributes::AUTHENTICATION_ERROR;
|
||||
$authErrorKey = Security::AUTHENTICATION_ERROR;
|
||||
|
||||
$error = null;
|
||||
|
||||
|
||||
@@ -33,8 +33,6 @@ use App\Repository\CustomerRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\Query\TeamQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\Utils\DataTable;
|
||||
use App\Utils\PageSetup;
|
||||
@@ -309,15 +307,6 @@ final class CustomerController extends AbstractController
|
||||
$rates = [];
|
||||
$now = $this->getDateTimeFactory()->createDateTime();
|
||||
|
||||
$exportUrl = null;
|
||||
$invoiceUrl = null;
|
||||
if ($this->isGranted('create_export')) {
|
||||
$exportUrl = $this->generateUrl('export', ['customers[]' => $customer->getId(), 'projects[]' => '', 'daterange' => '', 'exported' => TimesheetQuery::STATE_NOT_EXPORTED, 'preview' => true, 'billable' => true]);
|
||||
}
|
||||
if ($this->isGranted('view_invoice')) {
|
||||
$invoiceUrl = $this->generateUrl('invoice', ['customers[]' => $customer->getId(), 'projects[]' => '', 'daterange' => '', 'exported' => TimesheetQuery::STATE_NOT_EXPORTED, 'billable' => true]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('edit', $customer)) {
|
||||
if ($this->isGranted('create_team')) {
|
||||
$defaultTeam = $teamRepository->findOneBy(['name' => $customer->getName()]);
|
||||
@@ -339,9 +328,7 @@ final class CustomerController extends AbstractController
|
||||
}
|
||||
|
||||
if ($this->isGranted('permissions', $customer) || $this->isGranted('details', $customer) || $this->isGranted('view_team')) {
|
||||
$query = new TeamQuery();
|
||||
$query->addCustomer($customer);
|
||||
$teams = $teamRepository->getTeamsForQuery($query);
|
||||
$teams = $customer->getTeams();
|
||||
}
|
||||
|
||||
// additional boxes by plugins
|
||||
@@ -366,9 +353,7 @@ final class CustomerController extends AbstractController
|
||||
'customer_now' => new \DateTime('now', $timezone),
|
||||
'rates' => $rates,
|
||||
'now' => $now,
|
||||
'boxes' => $boxes,
|
||||
'export_url' => $exportUrl,
|
||||
'invoice_url' => $invoiceUrl,
|
||||
'boxes' => $boxes
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ use App\Event\PermissionSectionsEvent;
|
||||
use App\Event\PermissionsEvent;
|
||||
use App\Form\RoleType;
|
||||
use App\Model\PermissionSection;
|
||||
use App\Repository\Query\UserQuery;
|
||||
use App\Repository\RoleRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Security\RolePermissionManager;
|
||||
@@ -47,7 +46,7 @@ final class PermissionController extends AbstractController
|
||||
|
||||
#[Route(path: '', name: 'admin_user_permissions', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('role_permissions')]
|
||||
public function permissions(EventDispatcherInterface $dispatcher, CsrfTokenManagerInterface $csrfTokenManager, RoleService $roleService, UserRepository $userRepository): Response
|
||||
public function permissions(EventDispatcherInterface $dispatcher, CsrfTokenManagerInterface $csrfTokenManager, RoleService $roleService): Response
|
||||
{
|
||||
$all = $this->roleRepository->findAll();
|
||||
$existing = [];
|
||||
@@ -152,10 +151,7 @@ final class PermissionController extends AbstractController
|
||||
$page->setHelp('permissions.html');
|
||||
$page->setActionName('user_permissions');
|
||||
|
||||
$users = $userRepository->getUsersForQuery(new UserQuery());
|
||||
|
||||
return $this->render('permission/permissions.html.twig', [
|
||||
'users' => $users,
|
||||
'page_setup' => $page,
|
||||
'token' => $csrfTokenManager->refreshToken(self::TOKEN_NAME)->getValue(),
|
||||
'roles' => array_values($roles),
|
||||
|
||||
@@ -28,6 +28,7 @@ final class PluginController extends AbstractController
|
||||
$installed = [];
|
||||
$plugins = $manager->getPlugins();
|
||||
foreach ($plugins as $plugin) {
|
||||
$manager->loadMetadata($plugin);
|
||||
$installed[] = $plugin->getId();
|
||||
}
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ final class ProfileController extends AbstractController
|
||||
public function preferencesAction(User $profile, Request $request, EventDispatcherInterface $dispatcher, UserRepository $userRepository): Response
|
||||
{
|
||||
// we need to prepare the user preferences, which is done via an EventSubscriber
|
||||
$event = new PrepareUserEvent($profile, false);
|
||||
$event = new PrepareUserEvent($profile);
|
||||
$dispatcher->dispatch($event);
|
||||
|
||||
$form = $this->createPreferencesForm($profile);
|
||||
@@ -288,7 +288,7 @@ final class ProfileController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('user/preferences.html.twig', [
|
||||
'tab' => 'settings',
|
||||
'tab' => 'preferences',
|
||||
'user' => $profile,
|
||||
'form' => $form->createView(),
|
||||
'sections' => $sections
|
||||
@@ -318,7 +318,6 @@ final class ProfileController extends AbstractController
|
||||
'include_active_flag' => ($user->getId() !== $this->getUser()->getId()),
|
||||
'include_preferences' => true,
|
||||
'include_supervisor' => $this->isGranted('supervisor', $user),
|
||||
'include_password_reset' => $this->isGranted('password', $user),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@ use App\Repository\ProjectRateRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\Query\TeamQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\Utils\Context;
|
||||
use App\Utils\DataTable;
|
||||
@@ -336,15 +334,6 @@ final class ProjectController extends AbstractController
|
||||
$rates = [];
|
||||
$now = $this->getDateTimeFactory()->createDateTime();
|
||||
|
||||
$exportUrl = null;
|
||||
$invoiceUrl = null;
|
||||
if ($this->isGranted('create_export') && $project->getCustomer() !== null) {
|
||||
$exportUrl = $this->generateUrl('export', ['customers[]' => $project->getCustomer()->getId(), 'projects[]' => $project->getId(), 'daterange' => '', 'exported' => TimesheetQuery::STATE_NOT_EXPORTED, 'preview' => true, 'billable' => true]);
|
||||
}
|
||||
if ($this->isGranted('view_invoice') && $project->getCustomer() !== null) {
|
||||
$invoiceUrl = $this->generateUrl('invoice', ['customers[]' => $project->getCustomer()->getId(), 'projects[]' => $project->getId(), 'daterange' => '', 'exported' => TimesheetQuery::STATE_NOT_EXPORTED, 'billable' => true]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('edit', $project)) {
|
||||
if ($this->isGranted('create_team')) {
|
||||
$defaultTeam = $teamRepository->findOneBy(['name' => $project->getName()]);
|
||||
@@ -362,9 +351,7 @@ final class ProjectController extends AbstractController
|
||||
}
|
||||
|
||||
if ($this->isGranted('permissions', $project) || $this->isGranted('details', $project) || $this->isGranted('view_team')) {
|
||||
$query = new TeamQuery();
|
||||
$query->addProject($project);
|
||||
$teams = $teamRepository->getTeamsForQuery($query);
|
||||
$teams = $project->getTeams();
|
||||
}
|
||||
|
||||
// additional boxes by plugins
|
||||
@@ -388,9 +375,7 @@ final class ProjectController extends AbstractController
|
||||
'teams' => $teams,
|
||||
'rates' => $rates,
|
||||
'now' => $now,
|
||||
'boxes' => $boxes,
|
||||
'export_url' => $exportUrl,
|
||||
'invoice_url' => $invoiceUrl,
|
||||
'boxes' => $boxes
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
53
src/Controller/Reporting/CustomerViewController.php
Normal file
53
src/Controller/Reporting/CustomerViewController.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Controller\Reporting;
|
||||
|
||||
use App\Controller\AbstractController;
|
||||
use App\Customer\CustomerStatisticService;
|
||||
use App\Reporting\CustomerView\CustomerViewForm;
|
||||
use App\Reporting\CustomerView\CustomerViewQuery;
|
||||
use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
final class CustomerViewController extends AbstractController
|
||||
{
|
||||
#[Route(path: '/reporting/customer_view', name: 'report_customer_view', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('report:customer')]
|
||||
#[IsGranted(new Expression("is_granted('budget_any', 'customer')"))]
|
||||
public function __invoke(Request $request, CustomerStatisticService $service): Response
|
||||
{
|
||||
$dateFactory = $this->getDateTimeFactory();
|
||||
$user = $this->getUser();
|
||||
|
||||
$query = new CustomerViewQuery($dateFactory->createDateTime(), $user);
|
||||
$form = $this->createFormForGetRequest(CustomerViewForm::class, $query);
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
$customers = $service->findCustomersForView($query);
|
||||
$entries = $service->getCustomerView($user, $customers, $query->getToday());
|
||||
|
||||
$byCustomer = [];
|
||||
foreach ($entries as $entry) {
|
||||
$customer = $entry->getCustomer();
|
||||
$byCustomer[$customer->getId()] = $entry;
|
||||
}
|
||||
|
||||
return $this->render('reporting/customer_view.html.twig', [
|
||||
'entries' => $byCustomer,
|
||||
'form' => $form->createView(),
|
||||
'report_title' => 'report_customer_view',
|
||||
'tableName' => 'customer_view_reporting',
|
||||
'now' => $dateFactory->createDateTime(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -30,15 +30,14 @@ final class ProjectDateRangeController extends AbstractController
|
||||
$dateFactory = $this->getDateTimeFactory();
|
||||
$user = $this->getUser();
|
||||
|
||||
$defaultStart = $dateFactory->getStartOfMonth();
|
||||
$query = new ProjectDaterangeQuery($defaultStart, $user);
|
||||
$query = new ProjectDaterangeQuery($dateFactory->getStartOfMonth(), $user);
|
||||
$form = $this->createFormForGetRequest(ProjectDateRangeForm::class, $query, [
|
||||
'timezone' => $user->getTimezone()
|
||||
]);
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
$dateRange = new DateRange(true);
|
||||
$dateRange->setBegin($query->getMonth() ?? $defaultStart);
|
||||
$dateRange->setBegin($query->getMonth());
|
||||
$dateRange->setEnd($dateFactory->getEndOfMonth($dateRange->getBegin()));
|
||||
|
||||
$projects = $service->findProjectsForDateRange($query, $dateRange);
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Event\EmailPasswordResetEvent;
|
||||
use App\Form\PasswordResetForm;
|
||||
use App\User\LoginManager;
|
||||
use App\User\UserService;
|
||||
use DateTime;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
@@ -64,7 +65,7 @@ final class PasswordResetController extends AbstractController
|
||||
$username = $request->request->get('username');
|
||||
$user = $this->userService->findUserByUsernameOrEmail($username);
|
||||
|
||||
if (!$user->isPasswordRequestNonExpired($this->configuration->getPasswordResetRetryLifetime())) {
|
||||
if (null !== $user && !$user->isPasswordRequestNonExpired($this->configuration->getPasswordResetRetryLifetime())) {
|
||||
if (!$user->isInternalUser()) {
|
||||
throw $this->createAccessDeniedException(
|
||||
sprintf('The user "%s" tried to reset the password, but it is registered as "%s" auth-type.', $user->getUserIdentifier(), $user->getAuth())
|
||||
@@ -82,7 +83,7 @@ final class PasswordResetController extends AbstractController
|
||||
// this will finally send the email
|
||||
$this->eventDispatcher->dispatch(new EmailEvent($event->getEmail()));
|
||||
|
||||
$user->markPasswordRequested();
|
||||
$user->setPasswordRequestedAt(new DateTime());
|
||||
$this->userService->updateUser($user);
|
||||
}
|
||||
|
||||
@@ -137,7 +138,8 @@ final class PasswordResetController extends AbstractController
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$user->markPasswordResetted();
|
||||
$user->setConfirmationToken(null);
|
||||
$user->setPasswordRequestedAt(null);
|
||||
$user->setEnabled(true);
|
||||
|
||||
$this->userService->updateUser($user);
|
||||
|
||||
@@ -11,7 +11,6 @@ namespace App\Controller;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\User;
|
||||
use App\Event\PrepareUserEvent;
|
||||
use App\Event\UserPreferenceDisplayEvent;
|
||||
use App\Export\Spreadsheet\UserExporter;
|
||||
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||
@@ -71,8 +70,8 @@ final class UserController extends AbstractController
|
||||
$table->setReloadEvents('kimai.userUpdate');
|
||||
|
||||
$table->addColumn('avatar', ['class' => 'alwaysVisible w-avatar', 'title' => null, 'orderBy' => false]);
|
||||
//$table->addColumn('user', ['class' => 'alwaysVisible', 'orderBy' => 'user']);
|
||||
$table->addColumn('username', ['class' => 'alwaysVisible']);
|
||||
$table->addColumn('user', ['class' => 'alwaysVisible', 'orderBy' => 'user']);
|
||||
$table->addColumn('username', ['class' => 'd-none']);
|
||||
$table->addColumn('alias', ['class' => 'd-none']);
|
||||
$table->addColumn('account_number', ['class' => 'd-none']);
|
||||
$table->addColumn('title', ['class' => 'd-none']);
|
||||
@@ -113,7 +112,7 @@ final class UserController extends AbstractController
|
||||
|
||||
#[Route(path: '/create', name: 'admin_user_create', methods: ['GET', 'POST'])]
|
||||
#[IsGranted('create_user')]
|
||||
public function createAction(Request $request, SystemConfiguration $config, UserRepository $userRepository, EventDispatcherInterface $dispatcher): Response
|
||||
public function createAction(Request $request, SystemConfiguration $config, UserRepository $userRepository): Response
|
||||
{
|
||||
$user = $this->createNewDefaultUser($config);
|
||||
$editForm = $this->getCreateUserForm($user);
|
||||
@@ -127,14 +126,6 @@ final class UserController extends AbstractController
|
||||
$userRepository->saveUser($user);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
try {
|
||||
$event = new PrepareUserEvent($user, false);
|
||||
$dispatcher->dispatch($event);
|
||||
$userRepository->saveUser($user);
|
||||
} catch (\Exception $ex) {
|
||||
// it should be no problem, if creating default user preferences fails
|
||||
}
|
||||
|
||||
return $this->redirectToRouteAfterCreate('user_profile_edit', ['username' => $user->getUserIdentifier()]);
|
||||
}
|
||||
|
||||
|
||||
@@ -76,9 +76,9 @@ final class WizardController extends AbstractController
|
||||
$userService->updateUser($user);
|
||||
|
||||
if ($data['reload'] === '1') {
|
||||
return $this->redirectToRoute('wizard', ['wizard' => 'profile', '_locale' => $user->getLanguage()]);
|
||||
return $this->redirectToRoute('wizard', ['wizard' => 'profile', '_locale' => $data['language']]);
|
||||
} else {
|
||||
return $this->redirectToRoute('wizard', ['wizard' => $next, '_locale' => $user->getLanguage()]);
|
||||
return $this->redirectToRoute('wizard', ['wizard' => $next, '_locale' => $data['language']]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,23 +11,33 @@ namespace App\Customer;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\User;
|
||||
use App\Event\CustomerBudgetStatisticEvent;
|
||||
use App\Event\CustomerStatisticEvent;
|
||||
use App\Model\CustomerBudgetStatisticModel;
|
||||
use App\Model\CustomerStatistic;
|
||||
use App\Reporting\CustomerView\CustomerViewModel;
|
||||
use App\Reporting\CustomerView\CustomerViewQuery;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Repository\Loader\CustomerLoader;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use DateTime;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
/**
|
||||
* @final
|
||||
*/
|
||||
class CustomerStatisticService
|
||||
{
|
||||
public function __construct(private TimesheetRepository $timesheetRepository, private EventDispatcherInterface $dispatcher)
|
||||
public function __construct(
|
||||
private CustomerRepository $customerRepository,
|
||||
private TimesheetRepository $timesheetRepository,
|
||||
private EventDispatcherInterface $dispatcher
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -85,6 +95,7 @@ class CustomerStatisticService
|
||||
$result = $qb->getQuery()->getResult();
|
||||
|
||||
if (null !== $result) {
|
||||
/** @var array{'id': string, 'duration': int, 'internalRate': float, 'counter': int, 'rate': float, 'billable': int, 'exported': int} $resultRow */
|
||||
foreach ($result as $resultRow) {
|
||||
$statistic = $statistics[$resultRow['id']];
|
||||
$statistic->setDuration($statistic->getDuration() + $resultRow['duration']);
|
||||
@@ -113,12 +124,16 @@ class CustomerStatisticService
|
||||
return $statistics;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<Customer> $customers
|
||||
*/
|
||||
private function createStatisticQueryBuilder(array $customers, DateTime $begin = null, ?DateTime $end = null): QueryBuilder
|
||||
{
|
||||
$qb = $this->timesheetRepository->createQueryBuilder('t');
|
||||
$qb
|
||||
->select('IDENTITY(p.customer) AS id')
|
||||
->join(Project::class, 'p', Query\Expr\Join::WITH, 't.project = p.id')
|
||||
->join(Customer::class, 'c', Query\Expr\Join::WITH, 'p.customer = c.id')
|
||||
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
|
||||
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
|
||||
->addSelect('COALESCE(SUM(t.internalRate), 0) as internalRate')
|
||||
@@ -149,4 +164,177 @@ class CustomerStatisticService
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param CustomerViewQuery $query
|
||||
* @return Customer[]
|
||||
*/
|
||||
public function findCustomersForView(CustomerViewQuery $query): array
|
||||
{
|
||||
$user = $query->getUser();
|
||||
|
||||
$qb = $this->customerRepository->createQueryBuilder('c');
|
||||
$qb
|
||||
->select('c')
|
||||
->andWhere($qb->expr()->eq('c.visible', true))
|
||||
->addGroupBy('c')
|
||||
;
|
||||
|
||||
if ($query->isIncludeWithBudget()) {
|
||||
$qb->andWhere(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->gt('c.timeBudget', 0),
|
||||
$qb->expr()->gt('c.budget', 0)
|
||||
)
|
||||
);
|
||||
} elseif ($query->isIncludeWithoutBudget()) {
|
||||
$qb->andWhere(
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->eq('c.timeBudget', 0),
|
||||
$qb->expr()->eq('c.budget', 0)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$this->customerRepository->addPermissionCriteria($qb, $user);
|
||||
|
||||
/** @var Customer[] $customers */
|
||||
$customers = $qb->getQuery()->getResult();
|
||||
|
||||
// pre-cache customer objects instead of joining them
|
||||
$loader = new CustomerLoader($this->customerRepository->createQueryBuilder('c')->getEntityManager(), false);
|
||||
$loader->loadResults($customers);
|
||||
|
||||
return $customers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param User $user
|
||||
* @param Customer[] $customers
|
||||
* @param DateTime $today
|
||||
* @return CustomerViewModel[]
|
||||
*/
|
||||
public function getCustomerView(User $user, array $customers, DateTime $today): array
|
||||
{
|
||||
$today = clone $today;
|
||||
|
||||
/** @var array<int, CustomerViewModel> $customerViews */
|
||||
$customerViews = [];
|
||||
foreach ($customers as $customer) {
|
||||
$customerViews[$customer->getId()] = new CustomerViewModel($customer);
|
||||
}
|
||||
|
||||
$budgetStats = $this->getBudgetStatisticModelForCustomers($customers, $today);
|
||||
foreach ($budgetStats as $model) {
|
||||
$customerViews[$model->getCustomer()->getId()]->setBudgetStatisticModel($model);
|
||||
}
|
||||
|
||||
$customerIds = array_keys($customerViews);
|
||||
|
||||
$tplQb = $this->timesheetRepository->createQueryBuilder('t');
|
||||
$tplQb
|
||||
->select('c.id AS id')
|
||||
->join(Project::class, 'p', Query\Expr\Join::WITH, 't.project = p.id')
|
||||
->join(Customer::class, 'c', Query\Expr\Join::WITH, 'p.customer = c.id')
|
||||
->addSelect('COUNT(t.id) as amount')
|
||||
->addSelect('COALESCE(SUM(t.duration), 0) AS duration')
|
||||
->addSelect('COALESCE(SUM(t.rate), 0) AS rate')
|
||||
->andWhere($tplQb->expr()->in('c.id', ':customer'))
|
||||
->groupBy('id')
|
||||
->setParameter('customer', array_values($customerIds))
|
||||
;
|
||||
|
||||
$qb = clone $tplQb;
|
||||
|
||||
$result = $qb->getQuery()->getScalarResult();
|
||||
/** @var array{'duration': int, 'amount': int, 'rate': float, 'id': string, 'exported': int} $row */
|
||||
foreach ($result as $row) {
|
||||
$customerViews[$row['id']]->setDurationTotal($row['duration']);
|
||||
$customerViews[$row['id']]->setRateTotal($row['rate']);
|
||||
$customerViews[$row['id']]->setTimesheetCounter($row['amount']);
|
||||
}
|
||||
|
||||
$qb = clone $tplQb;
|
||||
$qb
|
||||
->addSelect('t.exported')
|
||||
->addSelect('t.billable')
|
||||
->addGroupBy('t.exported')
|
||||
->addGroupBy('t.billable')
|
||||
;
|
||||
$result = $qb->getQuery()->getScalarResult();
|
||||
/** @var array{'duration': int, 'billable': int, 'rate': float, 'exported': int, 'id': string} $row */
|
||||
foreach ($result as $row) {
|
||||
$view = $customerViews[$row['id']];
|
||||
if ($row['billable'] === 1 && $row['exported'] === 1) {
|
||||
$view->setBillableDuration($view->getBillableDuration() + $row['duration']);
|
||||
$view->setBillableRate($view->getBillableRate() + $row['rate']);
|
||||
} elseif ($row['billable'] === 1 && $row['exported'] === 0) {
|
||||
$view->setBillableDuration($view->getBillableDuration() + $row['duration']);
|
||||
$view->setBillableRate($view->getBillableRate() + $row['rate']);
|
||||
$view->setNotExportedDuration($view->getNotExportedDuration() + $row['duration']);
|
||||
$view->setNotExportedRate($view->getNotExportedRate() + $row['rate']);
|
||||
$view->setNotBilledDuration($view->getNotBilledDuration() + $row['duration']);
|
||||
$view->setNotBilledRate($view->getNotBilledRate() + $row['rate']);
|
||||
} elseif ($row['billable'] === 0 && $row['exported'] === 0) {
|
||||
$view->setNotExportedDuration($view->getNotExportedDuration() + $row['duration']);
|
||||
$view->setNotExportedRate($view->getNotExportedRate() + $row['rate']);
|
||||
}
|
||||
// the last possible case $row['billable'] === 0 && $row['exported'] === 1 is extremely unlikely and not used
|
||||
}
|
||||
|
||||
return array_values($customerViews);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Customer[] $customers
|
||||
* @param DateTime $today
|
||||
* @return CustomerBudgetStatisticModel[]
|
||||
*/
|
||||
public function getBudgetStatisticModelForCustomers(array $customers, DateTime $today): array
|
||||
{
|
||||
$models = [];
|
||||
$monthly = [];
|
||||
$allTime = [];
|
||||
|
||||
foreach ($customers as $customer) {
|
||||
$models[$customer->getId()] = new CustomerBudgetStatisticModel($customer);
|
||||
if ($customer->isMonthlyBudget()) {
|
||||
$monthly[] = $customer;
|
||||
} else {
|
||||
$allTime[] = $customer;
|
||||
}
|
||||
}
|
||||
|
||||
$statisticsTotal = $this->getBudgetStatistic($customers);
|
||||
foreach ($statisticsTotal as $id => $statistic) {
|
||||
$models[$id]->setStatisticTotal($statistic);
|
||||
}
|
||||
|
||||
$dateFactory = new DateTimeFactory($today->getTimezone());
|
||||
|
||||
$begin = null;
|
||||
$end = $today;
|
||||
|
||||
if (\count($monthly) > 0) {
|
||||
$begin = $dateFactory->getStartOfMonth($today);
|
||||
$end = $dateFactory->getEndOfMonth($today);
|
||||
$statistics = $this->getBudgetStatistic($monthly, $begin, $end);
|
||||
foreach ($statistics as $id => $statistic) {
|
||||
$models[$id]->setStatistic($statistic);
|
||||
}
|
||||
}
|
||||
|
||||
if (\count($allTime) > 0) {
|
||||
// display the budget at the end of the selected period and not the total sum of all times (do not include times in the future)
|
||||
$statistics = $this->getBudgetStatistic($allTime, null, $today);
|
||||
foreach ($statistics as $id => $statistic) {
|
||||
$models[$id]->setStatistic($statistic);
|
||||
}
|
||||
}
|
||||
|
||||
$event = new CustomerBudgetStatisticEvent($models, $begin, $end);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
return $models;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,8 @@
|
||||
|
||||
namespace App\DependencyInjection\Compiler;
|
||||
|
||||
use App\Export\ExportRepositoryInterface;
|
||||
use App\Export\RendererInterface;
|
||||
use App\Export\ServiceExport;
|
||||
use App\Export\TimesheetExportInterface;
|
||||
use App\Kernel;
|
||||
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Reference;
|
||||
@@ -26,17 +24,17 @@ final class ExportServiceCompilerPass implements CompilerPassInterface
|
||||
{
|
||||
$definition = $container->findDefinition(ServiceExport::class);
|
||||
|
||||
$taggedRenderer = $container->findTaggedServiceIds(RendererInterface::class);
|
||||
$taggedRenderer = $container->findTaggedServiceIds(Kernel::TAG_EXPORT_RENDERER);
|
||||
foreach ($taggedRenderer as $id => $tags) {
|
||||
$definition->addMethodCall('addRenderer', [new Reference($id)]);
|
||||
}
|
||||
|
||||
$taggedExporter = $container->findTaggedServiceIds(TimesheetExportInterface::class);
|
||||
$taggedExporter = $container->findTaggedServiceIds(Kernel::TAG_TIMESHEET_EXPORTER);
|
||||
foreach ($taggedExporter as $id => $tags) {
|
||||
$definition->addMethodCall('addTimesheetExporter', [new Reference($id)]);
|
||||
}
|
||||
|
||||
$taggedRepository = $container->findTaggedServiceIds(ExportRepositoryInterface::class);
|
||||
$taggedRepository = $container->findTaggedServiceIds(Kernel::TAG_EXPORT_REPOSITORY);
|
||||
foreach ($taggedRepository as $id => $tags) {
|
||||
$definition->addMethodCall('addExportRepository', [new Reference($id)]);
|
||||
}
|
||||
|
||||
@@ -9,11 +9,8 @@
|
||||
|
||||
namespace App\DependencyInjection\Compiler;
|
||||
|
||||
use App\Invoice\CalculatorInterface;
|
||||
use App\Invoice\InvoiceItemRepositoryInterface;
|
||||
use App\Invoice\NumberGeneratorInterface;
|
||||
use App\Invoice\RendererInterface;
|
||||
use App\Invoice\ServiceInvoice;
|
||||
use App\Kernel;
|
||||
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Reference;
|
||||
@@ -27,22 +24,22 @@ final class InvoiceServiceCompilerPass implements CompilerPassInterface
|
||||
{
|
||||
$definition = $container->findDefinition(ServiceInvoice::class);
|
||||
|
||||
$taggedRenderer = $container->findTaggedServiceIds(RendererInterface::class);
|
||||
$taggedRenderer = $container->findTaggedServiceIds(Kernel::TAG_INVOICE_RENDERER);
|
||||
foreach ($taggedRenderer as $id => $tags) {
|
||||
$definition->addMethodCall('addRenderer', [new Reference($id)]);
|
||||
}
|
||||
|
||||
$taggedGenerator = $container->findTaggedServiceIds(NumberGeneratorInterface::class);
|
||||
$taggedGenerator = $container->findTaggedServiceIds(Kernel::TAG_INVOICE_NUMBER_GENERATOR);
|
||||
foreach ($taggedGenerator as $id => $tags) {
|
||||
$definition->addMethodCall('addNumberGenerator', [new Reference($id)]);
|
||||
}
|
||||
|
||||
$taggedCalculator = $container->findTaggedServiceIds(CalculatorInterface::class);
|
||||
$taggedCalculator = $container->findTaggedServiceIds(Kernel::TAG_INVOICE_CALCULATOR);
|
||||
foreach ($taggedCalculator as $id => $tags) {
|
||||
$definition->addMethodCall('addCalculator', [new Reference($id)]);
|
||||
}
|
||||
|
||||
$taggedRepository = $container->findTaggedServiceIds(InvoiceItemRepositoryInterface::class);
|
||||
$taggedRepository = $container->findTaggedServiceIds(Kernel::TAG_INVOICE_REPOSITORY);
|
||||
foreach ($taggedRepository as $id => $tags) {
|
||||
$definition->addMethodCall('addInvoiceItemRepository', [new Reference($id)]);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace App\DependencyInjection\Compiler;
|
||||
|
||||
use App\Widget\WidgetInterface;
|
||||
use App\Kernel;
|
||||
use App\Widget\WidgetService;
|
||||
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
@@ -24,7 +24,7 @@ final class WidgetCompilerPass implements CompilerPassInterface
|
||||
{
|
||||
$definition = $container->findDefinition(WidgetService::class);
|
||||
|
||||
$taggedRenderer = $container->findTaggedServiceIds(WidgetInterface::class);
|
||||
$taggedRenderer = $container->findTaggedServiceIds(Kernel::TAG_WIDGET);
|
||||
foreach ($taggedRenderer as $id => $tags) {
|
||||
$definition->addMethodCall('registerWidget', [new Reference($id)]);
|
||||
}
|
||||
|
||||
@@ -1,15 +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\Doctrine;
|
||||
|
||||
interface ModifiedAt
|
||||
{
|
||||
public function setModifiedAt(\DateTimeImmutable $dateTime): void;
|
||||
}
|
||||
@@ -9,13 +9,14 @@
|
||||
|
||||
namespace App\Doctrine;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
|
||||
/**
|
||||
* Automatically set the modifiedAt field for all ModifiedAt instances.
|
||||
* Automatically set the modifiedAt field for all Timesheet entries.
|
||||
*/
|
||||
#[AsDoctrineListener(event: Events::onFlush, priority: 60)]
|
||||
final class ModifiedSubscriber implements EventSubscriber, DataSubscriberInterface
|
||||
@@ -33,15 +34,17 @@ final class ModifiedSubscriber implements EventSubscriber, DataSubscriberInterfa
|
||||
$now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
|
||||
|
||||
foreach ($uow->getScheduledEntityUpdates() as $entity) {
|
||||
if ($entity instanceof ModifiedAt) {
|
||||
$entity->setModifiedAt($now);
|
||||
if (!($entity instanceof Timesheet)) {
|
||||
continue;
|
||||
}
|
||||
$entity->setModifiedAt($now);
|
||||
}
|
||||
|
||||
foreach ($uow->getScheduledEntityInsertions() as $entity) {
|
||||
if ($entity instanceof ModifiedAt) {
|
||||
$entity->setModifiedAt($now);
|
||||
if (!($entity instanceof Timesheet)) {
|
||||
continue;
|
||||
}
|
||||
$entity->setModifiedAt($now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,9 @@ use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
|
||||
|
||||
/**
|
||||
* A listener to make sure all Timesheet entries will be calculated properly.
|
||||
*
|
||||
* E.g. updates timesheet records and applies configured rate & rounding rules.
|
||||
* A listener to make sure all Timesheet entries will be calculated properly (e.g. duration and rates).
|
||||
*/
|
||||
#[AsDoctrineListener(event: Events::onFlush, priority: 50)]
|
||||
final class TimesheetSubscriber implements EventSubscriber, DataSubscriberInterface
|
||||
@@ -33,10 +30,7 @@ final class TimesheetSubscriber implements EventSubscriber, DataSubscriberInterf
|
||||
/**
|
||||
* @param CalculatorInterface[] $calculators
|
||||
*/
|
||||
public function __construct(
|
||||
#[TaggedIterator(CalculatorInterface::class)]
|
||||
private readonly iterable $calculators
|
||||
)
|
||||
public function __construct(private iterable $calculators)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -13,20 +13,25 @@ use Doctrine\Common\Collections\Collection;
|
||||
|
||||
interface ExportableItem
|
||||
{
|
||||
public function getId(): ?int;
|
||||
|
||||
/**
|
||||
* Whether this item was already exported.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isExported(): bool;
|
||||
|
||||
/**
|
||||
* Whether this item should be included in invoices.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isBillable(): bool;
|
||||
|
||||
/**
|
||||
* Returns the named meta field or null.
|
||||
*
|
||||
* @param string $name
|
||||
* @return MetaTableTypeInterface|null
|
||||
*/
|
||||
public function getMetaField(string $name): ?MetaTableTypeInterface;
|
||||
|
||||
@@ -39,6 +44,8 @@ interface ExportableItem
|
||||
|
||||
/**
|
||||
* Returns the amount for this item.
|
||||
*
|
||||
* @return float
|
||||
*/
|
||||
public function getAmount(): float;
|
||||
|
||||
@@ -76,13 +83,15 @@ interface ExportableItem
|
||||
|
||||
/**
|
||||
* A name representation for this type of item.
|
||||
* Example: "timesheet"
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getType(): string;
|
||||
|
||||
/**
|
||||
* A name representation for the category of this item.
|
||||
* Example: "work"
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getCategory(): string;
|
||||
}
|
||||
|
||||
@@ -36,12 +36,15 @@ trait MetaTableTypeTrait
|
||||
private ?string $name = null;
|
||||
/**
|
||||
* Value of the meta (custom) field
|
||||
*
|
||||
* ATTENTION:
|
||||
* This field can be used to temporary hold data in another format (e.g. array) during form transformation,
|
||||
*/
|
||||
#[ORM\Column(name: 'value', type: 'text', length: 65535, nullable: true)]
|
||||
#[Assert\Length(max: 65535)]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
private ?string $value = null;
|
||||
private mixed $value = null;
|
||||
#[ORM\Column(name: 'visible', type: 'boolean', nullable: false, options: ['default' => false])]
|
||||
#[Assert\NotNull]
|
||||
private bool $visible = false;
|
||||
@@ -58,14 +61,6 @@ trait MetaTableTypeTrait
|
||||
*/
|
||||
private array $options = [];
|
||||
private int $order = 0;
|
||||
/**
|
||||
* Used for data conversion during form transformation.
|
||||
*
|
||||
* ATTENTION: This field can be used to temporary hold data in another format (e.g. array) during form transformation.
|
||||
* TODO unclear when "array" should happen. the above statement is old and maybe we can remove the "mixed” type
|
||||
*/
|
||||
private mixed $data = null;
|
||||
private bool $updated = false;
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
@@ -85,47 +80,41 @@ trait MetaTableTypeTrait
|
||||
|
||||
public function getValue(): mixed
|
||||
{
|
||||
$value = $this->updated ? $this->data : $this->value;
|
||||
|
||||
if ($value === null) {
|
||||
if ($this->value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($this->type) {
|
||||
YesNoType::class, CheckboxType::class => (\is_string($value) || \is_int($value)) ? (bool) $value : $value,
|
||||
IntegerType::class => (\is_string($value) || \is_int($value)) ? (int) $value : $value,
|
||||
NumberType::class => (\is_string($value) || \is_float($value)) ? (float) $value : $value,
|
||||
default => $value
|
||||
YesNoType::class, CheckboxType::class => (\is_string($this->value) || \is_int($this->value)) ? (bool) $this->value : $this->value,
|
||||
IntegerType::class => (\is_string($this->value) || \is_int($this->value)) ? (int) $this->value : $this->value,
|
||||
NumberType::class => (\is_string($this->value) || \is_float($this->value)) ? (float) $this->value : $this->value,
|
||||
default => $this->value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Value will not be serialized before its stored, so it should be a primitive/scalar type.
|
||||
* Value will not be serialized before its stored, so it should be a primitive type.
|
||||
*
|
||||
* @param mixed $value
|
||||
* @return MetaTableTypeInterface
|
||||
*/
|
||||
public function setValue(mixed $value): MetaTableTypeInterface
|
||||
{
|
||||
$this->data = $value;
|
||||
$this->updated = true;
|
||||
|
||||
// unchecked checkboxes / false bool would save an empty string in the database
|
||||
// those cannot be searched in the database
|
||||
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');
|
||||
if (\is_string($value) || \is_bool($value)) {
|
||||
$value = (int) $value;
|
||||
} else {
|
||||
$value = (string) $value;
|
||||
throw new \InvalidArgumentException('Failed converting meta-field bool value');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
$this->value = $value;
|
||||
} elseif (\is_scalar($value)) {
|
||||
$this->value = (string) $value;
|
||||
}
|
||||
$this->value = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -46,9 +46,4 @@ class Role
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isUser(): bool
|
||||
{
|
||||
return $this->name === User::ROLE_USER;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,8 +52,6 @@ class Tag
|
||||
use ColorTrait;
|
||||
|
||||
/**
|
||||
* This is ONLY here, so we can count the amount of timesheets.
|
||||
*
|
||||
* @var Collection<Timesheet>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Timesheet::class, mappedBy: 'tags', fetch: 'EXTRA_LAZY')]
|
||||
@@ -91,6 +89,26 @@ class Tag
|
||||
$this->visible = $visible;
|
||||
}
|
||||
|
||||
public function addTimesheet(Timesheet $timesheet): void
|
||||
{
|
||||
if ($this->timesheets->contains($timesheet)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->timesheets->add($timesheet);
|
||||
$timesheet->addTag($this);
|
||||
}
|
||||
|
||||
public function removeTimesheet(Timesheet $timesheet): void
|
||||
{
|
||||
if (!$this->timesheets->contains($timesheet)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->timesheets->removeElement($timesheet);
|
||||
$timesheet->removeTag($this);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->getName();
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Doctrine\ModifiedAt;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
@@ -46,7 +45,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[Serializer\VirtualProperty('TagsAsArray', exp: 'object.getTagsAsArray()', options: [new Serializer\SerializedName('tags'), new Serializer\Type(name: 'array<string>'), new Serializer\Groups(['Default'])])]
|
||||
#[Constraints\Timesheet]
|
||||
#[Constraints\TimesheetDeactivated]
|
||||
class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
class Timesheet implements EntityWithMetaFields, ExportableItem
|
||||
{
|
||||
/**
|
||||
* Category: Normal work-time (default category)
|
||||
@@ -189,8 +188,8 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
#[ORM\Column(name: 'category', type: 'string', length: 10, nullable: false, options: ['default' => 'work'])]
|
||||
#[Assert\NotNull]
|
||||
private ?string $category = self::WORK;
|
||||
#[ORM\Column(name: 'modified_at', type: 'datetime_immutable', nullable: true)]
|
||||
private \DateTimeImmutable $modifiedAt; // @phpstan-ignore-line - create migration and update all null values and then make it not null
|
||||
#[ORM\Column(name: 'modified_at', type: 'datetime', nullable: true)]
|
||||
private \DateTimeInterface $modifiedAt;
|
||||
/**
|
||||
* Tags
|
||||
*
|
||||
@@ -412,6 +411,10 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
return $this->internalRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Tag $tag
|
||||
* @return Timesheet
|
||||
*/
|
||||
public function addTag(Tag $tag): Timesheet
|
||||
{
|
||||
if ($this->tags->contains($tag)) {
|
||||
@@ -422,6 +425,9 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Tag $tag
|
||||
*/
|
||||
public function removeTag(Tag $tag): void
|
||||
{
|
||||
if (!$this->tags->contains($tag)) {
|
||||
@@ -580,12 +586,12 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getModifiedAt(): \DateTimeImmutable
|
||||
public function getModifiedAt(): \DateTimeInterface
|
||||
{
|
||||
return $this->modifiedAt;
|
||||
}
|
||||
|
||||
public function setModifiedAt(\DateTimeImmutable $dateTime): void
|
||||
public function setModifiedAt(\DateTimeInterface $dateTime): void
|
||||
{
|
||||
$this->modifiedAt = $dateTime;
|
||||
}
|
||||
|
||||
@@ -196,8 +196,8 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
#[ORM\Column(name: 'confirmation_token', type: 'string', length: 180, unique: true, nullable: true)]
|
||||
#[Assert\Length(max: 180)]
|
||||
private ?string $confirmationToken = null;
|
||||
#[ORM\Column(name: 'password_requested_at', type: 'datetime_immutable', nullable: true)]
|
||||
private ?\DateTimeImmutable $passwordRequestedAt = null;
|
||||
#[ORM\Column(name: 'password_requested_at', type: 'datetime', nullable: true)]
|
||||
private ?\DateTime $passwordRequestedAt = null;
|
||||
/**
|
||||
* List of all role names
|
||||
*/
|
||||
@@ -958,31 +958,26 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setConfirmationToken($confirmationToken): void
|
||||
public function setConfirmationToken($confirmationToken): User
|
||||
{
|
||||
$this->confirmationToken = $confirmationToken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function markPasswordRequested(): void
|
||||
{
|
||||
$this->setPasswordRequestedAt(new \DateTimeImmutable('now', new \DateTimeZone($this->getTimezone())));
|
||||
}
|
||||
|
||||
public function markPasswordResetted(): void
|
||||
{
|
||||
$this->setConfirmationToken(null);
|
||||
$this->setPasswordRequestedAt(null);
|
||||
}
|
||||
|
||||
public function setPasswordRequestedAt(?\DateTimeImmutable $date): void
|
||||
public function setPasswordRequestedAt(?\DateTime $date = null): User
|
||||
{
|
||||
$this->passwordRequestedAt = $date;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the timestamp that the user requested a password reset.
|
||||
*
|
||||
* @return DateTime|null
|
||||
*/
|
||||
public function getPasswordRequestedAt(): ?\DateTimeImmutable
|
||||
public function getPasswordRequestedAt(): ?DateTime
|
||||
{
|
||||
return $this->passwordRequestedAt;
|
||||
}
|
||||
@@ -1023,10 +1018,6 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->enabled !== $user->isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,10 @@ class UserPreference
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $id
|
||||
* @return UserPreference
|
||||
*/
|
||||
public function setId(int $id): UserPreference
|
||||
{
|
||||
$this->id = $id;
|
||||
@@ -107,13 +111,7 @@ class UserPreference
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
$sanitized = $this->sanitizeName($this->name);
|
||||
|
||||
if ($sanitized !== $this->name) {
|
||||
$this->name = $sanitized;
|
||||
}
|
||||
|
||||
return $this->name;
|
||||
return $this->sanitizeName($this->name);
|
||||
}
|
||||
|
||||
public function matches(string $name): bool
|
||||
@@ -121,7 +119,7 @@ class UserPreference
|
||||
return $this->sanitizeName($name) === $this->getName();
|
||||
}
|
||||
|
||||
private function sanitizeName(?string $name): string
|
||||
public function sanitizeName(?string $name): string
|
||||
{
|
||||
return str_replace(['.', '-'], '_', $name);
|
||||
}
|
||||
@@ -152,6 +150,9 @@ class UserPreference
|
||||
|
||||
/**
|
||||
* Sets the form type to edit that setting.
|
||||
*
|
||||
* @param string $type
|
||||
* @return UserPreference
|
||||
*/
|
||||
public function setType(string $type): UserPreference
|
||||
{
|
||||
|
||||
@@ -41,9 +41,9 @@ class WorkingTime
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'approved_by', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $approvedBy = null;
|
||||
#[ORM\Column(name: 'approved_at', type: 'datetime_immutable', nullable: true)]
|
||||
#[ORM\Column(name: 'approved_at', type: 'datetime', nullable: true)]
|
||||
#[Assert\NotNull]
|
||||
private ?\DateTimeImmutable $approvedAt = null;
|
||||
private ?\DateTimeInterface $approvedAt = null;
|
||||
|
||||
public function __construct(User $user, \DateTimeInterface $date)
|
||||
{
|
||||
@@ -96,12 +96,12 @@ class WorkingTime
|
||||
$this->approvedBy = $approvedBy;
|
||||
}
|
||||
|
||||
public function getApprovedAt(): ?\DateTimeImmutable
|
||||
public function getApprovedAt(): ?\DateTimeInterface
|
||||
{
|
||||
return $this->approvedAt;
|
||||
}
|
||||
|
||||
public function setApprovedAt(?\DateTimeImmutable $approvedAt): void
|
||||
public function setApprovedAt(?\DateTimeInterface $approvedAt): void
|
||||
{
|
||||
$this->approvedAt = $approvedAt;
|
||||
}
|
||||
|
||||
57
src/Event/CustomerBudgetStatisticEvent.php
Normal file
57
src/Event/CustomerBudgetStatisticEvent.php
Normal 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\Event;
|
||||
|
||||
use App\Model\CustomerBudgetStatisticModel;
|
||||
|
||||
final class CustomerBudgetStatisticEvent
|
||||
{
|
||||
/**
|
||||
* @param CustomerBudgetStatisticModel[] $models
|
||||
* @param \DateTime|null $begin
|
||||
* @param \DateTime|null $end
|
||||
*/
|
||||
public function __construct(private array $models, private ?\DateTime $begin = null, private ?\DateTime $end = null)
|
||||
{
|
||||
}
|
||||
|
||||
public function getModel(int $customerId): ?CustomerBudgetStatisticModel
|
||||
{
|
||||
if (isset($this->models[$customerId])) {
|
||||
return $this->models[$customerId];
|
||||
}
|
||||
|
||||
foreach ($this->models as $model) {
|
||||
if ($model->getCustomer()->getId() === $customerId) {
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return CustomerBudgetStatisticModel[]
|
||||
*/
|
||||
public function getModels(): array
|
||||
{
|
||||
return $this->models;
|
||||
}
|
||||
|
||||
public function getBegin(): ?\DateTime
|
||||
{
|
||||
return $this->begin;
|
||||
}
|
||||
|
||||
public function getEnd(): ?\DateTime
|
||||
{
|
||||
return $this->end;
|
||||
}
|
||||
}
|
||||
@@ -151,13 +151,16 @@ class PageActionsEvent extends ThemeEvent
|
||||
$this->addAction('create', ['url' => $url, 'class' => ($modal ? 'modal-ajax-form' : ''), 'title' => 'create', 'accesskey' => 'a']);
|
||||
}
|
||||
|
||||
public function addEdit(string $url, bool $modal = true, string $class = ''): void
|
||||
public function addEdit(string $url, bool $modal = true): void
|
||||
{
|
||||
$this->addAction('edit', ['url' => $url, 'class' => ($modal ? 'modal-ajax-form' . ($class === '' ? '' : ' ' . $class) : $class), 'translation_domain' => 'actions', 'title' => 'edit']);
|
||||
$this->addAction('edit', ['url' => $url, 'class' => ($modal ? 'modal-ajax-form' : ''), 'translation_domain' => 'actions', 'title' => 'edit']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link to a configuration section.
|
||||
*
|
||||
* @param string $url
|
||||
* @return void
|
||||
*/
|
||||
public function addSettings(string $url): void
|
||||
{
|
||||
|
||||
@@ -13,24 +13,14 @@ use App\Entity\User;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
/**
|
||||
* To be used when a user profile is loaded and should be filled with dynamic user preferences.
|
||||
*
|
||||
* @internal
|
||||
* This event should be used, if a user profile is loaded and want to fill the dynamic user preferences
|
||||
*/
|
||||
final class PrepareUserEvent extends Event
|
||||
{
|
||||
public function __construct(private User $user, private bool $booting = true)
|
||||
public function __construct(private User $user)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this event is dispatched for the currently logged in user during kernel boot.
|
||||
*/
|
||||
public function isBooting(): bool
|
||||
{
|
||||
return $this->booting;
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
|
||||
@@ -19,7 +19,6 @@ class ThemeEvent extends Event
|
||||
public const HTML_HEAD = 'app.theme.html_head';
|
||||
public const CONTENT_BEFORE = 'app.theme.content_before';
|
||||
public const CONTENT_START = 'app.theme.content_start';
|
||||
public const TOOLBAR = 'app.theme.toolbar';
|
||||
public const CONTENT_END = 'app.theme.content_end';
|
||||
public const CONTENT_AFTER = 'app.theme.content_after';
|
||||
|
||||
|
||||
@@ -14,26 +14,18 @@ use App\Entity\UserPreference;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
/**
|
||||
* Add further user-preference definitions dynamically.
|
||||
* This is used on every page load, do not query the database when this is dispatched.
|
||||
* This event should be used, if further user preferences should be added dynamically.
|
||||
*/
|
||||
final class UserPreferenceEvent extends Event
|
||||
{
|
||||
/**
|
||||
* @param array<UserPreference> $preferences
|
||||
* @param User $user
|
||||
* @param UserPreference[] $preferences
|
||||
*/
|
||||
public function __construct(private readonly User $user, private array $preferences, private readonly bool $booting = true)
|
||||
public function __construct(private User $user, private array $preferences)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this event is dispatched for the currently logged in user during kernel boot.
|
||||
*/
|
||||
public function isBooting(): bool
|
||||
{
|
||||
return $this->booting;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do not set the preferences directly to the user object, but ONLY via addPreference()
|
||||
*
|
||||
|
||||
@@ -25,20 +25,20 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
|
||||
$timesheet = $payload['timesheet'];
|
||||
if ($timesheet->getId() !== null) {
|
||||
if ($timesheet->isRunning() && $this->isGranted('stop', $timesheet)) {
|
||||
$event->addAction('stop', ['url' => $this->path('stop_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link dd-ts-stop', 'attr' => ['data-event' => 'kimai.timesheetStop kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.stop.error', 'data-msg-success' => 'timesheet.stop.success']]);
|
||||
$event->addAction('stop', ['url' => $this->path('stop_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link', 'attr' => ['data-event' => 'kimai.timesheetStop kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.stop.error', 'data-msg-success' => 'timesheet.stop.success']]);
|
||||
}
|
||||
|
||||
if (!$timesheet->isRunning() && $this->isGranted('start', $timesheet)) {
|
||||
$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']]);
|
||||
$event->addAction('repeat', ['title' => 'repeat', 'translation_domain' => 'actions', 'url' => $this->path('restart_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link', '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)) {
|
||||
$event->addEdit($this->path($routeEdit, ['id' => $timesheet->getId()]), !$event->isView('edit'), 'dd-ts-edit');
|
||||
$event->addEdit($this->path($routeEdit, ['id' => $timesheet->getId()]), !$event->isView('edit'));
|
||||
}
|
||||
|
||||
if ($this->isGranted('duplicate', $timesheet)) {
|
||||
$class = $event->isView('edit') ? '' : 'modal-ajax-form';
|
||||
$event->addAction('copy', ['title' => 'copy', 'translation_domain' => 'actions', '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]);
|
||||
}
|
||||
|
||||
if ($event->countActions() > 0) {
|
||||
@@ -48,7 +48,7 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
|
||||
if (($event->isIndexView() || $event->isView('calendar')) && $this->isGranted('delete', $timesheet)) {
|
||||
$event->addAction('trash', [
|
||||
'url' => $this->path('delete_timesheet', ['id' => $timesheet->getId()]),
|
||||
'class' => 'api-link text-red dd-ts-trash',
|
||||
'class' => 'api-link text-red',
|
||||
'translation_domain' => 'actions',
|
||||
'attr' => [
|
||||
'data-event' => 'kimai.timesheetDelete',
|
||||
|
||||
@@ -33,9 +33,6 @@ 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', 'translation_domain' => 'actions']);
|
||||
}
|
||||
if ($this->isGranted('preferences', $user)) {
|
||||
$event->addConfig($this->path('user_profile_preferences', ['username' => $user->getUserIdentifier()]));
|
||||
}
|
||||
if ($this->isGranted('password', $user)) {
|
||||
$event->addAction('password', ['url' => $this->path('user_profile_password', ['username' => $user->getUserIdentifier()]), 'title' => 'profile.password']);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,10 @@ final class UserSubscriber extends AbstractActionsSubscriber
|
||||
$event->addActionToSubmenu('edit', $id, $action);
|
||||
}
|
||||
|
||||
if ($this->isGranted('preferences', $user)) {
|
||||
$event->addConfig($this->path('user_profile_preferences', ['username' => $user->getUserIdentifier()]));
|
||||
}
|
||||
|
||||
if (($event->getUser()->getId() === $user->getId() && $this->isGranted('report:user')) || $this->isGranted('report:other')) {
|
||||
$event->addActionToSubmenu('report', 'weekly', ['url' => $this->path('report_user_week', ['user' => $user->getId()]), 'translation_domain' => 'reporting', 'title' => 'report_user_week']);
|
||||
$event->addActionToSubmenu('report', 'monthly', ['url' => $this->path('report_user_month', ['user' => $user->getId()]), 'translation_domain' => 'reporting', 'title' => 'report_user_month']);
|
||||
|
||||
@@ -9,10 +9,13 @@
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Event\ConfigureMainMenuEvent;
|
||||
use App\Utils\MenuItemModel;
|
||||
use App\Utils\MenuService;
|
||||
use KevinPapst\TablerBundle\Event\MenuEvent;
|
||||
use KevinPapst\TablerBundle\Model\MenuItemInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
|
||||
/**
|
||||
@@ -21,7 +24,7 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
*/
|
||||
final class MenuBuilderSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private MenuService $menuService)
|
||||
public function __construct(private EventDispatcherInterface $eventDispatcher, private Security $security)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -34,7 +37,15 @@ final class MenuBuilderSubscriber implements EventSubscriberInterface
|
||||
|
||||
public function onSetupNavbar(MenuEvent $event): void
|
||||
{
|
||||
$menuEvent = $this->menuService->getKimaiMenu();
|
||||
$menuEvent = new ConfigureMainMenuEvent();
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->security->getUser();
|
||||
|
||||
// error pages don't have a user and will fail when is_granted() is called
|
||||
if (null !== $user) {
|
||||
$this->eventDispatcher->dispatch($menuEvent);
|
||||
}
|
||||
|
||||
foreach ($menuEvent->getMenu()->getChildren() as $child) {
|
||||
if ($child->getRoute() === null && !$child->hasChildren()) {
|
||||
|
||||
61
src/EventSubscriber/MenuFavoritesSubscriber.php
Normal file
61
src/EventSubscriber/MenuFavoritesSubscriber.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Event\ConfigureMainMenuEvent;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
|
||||
final class MenuFavoritesSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private Security $security)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
ConfigureMainMenuEvent::class => ['onMainMenuConfigure', 90], // see MenuSubscriber
|
||||
];
|
||||
}
|
||||
|
||||
public function onMainMenuConfigure(ConfigureMainMenuEvent $menuEvent): void
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = $this->security->getUser();
|
||||
if (null === $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userFavorites = $user->getPreferenceValue('favorite_routes');
|
||||
if (!\is_string($userFavorites) || trim($userFavorites) === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$favMenu = $menuEvent->findById('favorites');
|
||||
if ($favMenu === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userFavorites = explode(',', $userFavorites);
|
||||
foreach ($userFavorites as $fav) {
|
||||
$tmp = $menuEvent->findById($fav);
|
||||
if ($tmp !== null && !$tmp->hasChildren()) {
|
||||
$favMenu->addChild(clone $tmp);
|
||||
}
|
||||
}
|
||||
|
||||
if ($favMenu->hasChildren()) {
|
||||
$favMenu->setExpanded(true);
|
||||
$menuEvent->getTimesheetMenu()?->setExpanded(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,7 @@ final class UserPreferenceSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
$user = $event->getUser();
|
||||
|
||||
$event = new UserPreferenceEvent($user, $this->getDefaultPreferences($user), $event->isBooting());
|
||||
$event = new UserPreferenceEvent($user, $this->getDefaultPreferences($user));
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
|
||||
foreach ($event->getPreferences() as $preference) {
|
||||
|
||||
@@ -73,12 +73,8 @@ abstract class AbstractSpreadsheetRenderer
|
||||
'end' => [],
|
||||
'duration' => [],
|
||||
'rate' => [],
|
||||
'rate_internal' => [
|
||||
'label' => 'internalRate', // different translation key
|
||||
],
|
||||
'user' => [
|
||||
'label' => 'name'
|
||||
],
|
||||
'rate_internal' => [],
|
||||
'user' => [],
|
||||
'username' => [],
|
||||
'customer' => [],
|
||||
'project' => [],
|
||||
@@ -219,7 +215,7 @@ abstract class AbstractSpreadsheetRenderer
|
||||
|
||||
protected function setRate(Worksheet $sheet, int $column, int $row, ?float $rate, ?string $currency): void
|
||||
{
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $rate ?? 0.0);
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $rate ?? 0);
|
||||
$this->setRateStyle($sheet, $column, $row, $currency);
|
||||
}
|
||||
|
||||
@@ -313,6 +309,13 @@ abstract class AbstractSpreadsheetRenderer
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $username);
|
||||
};
|
||||
}
|
||||
if (!isset($columns['username']['header'])) {
|
||||
$columns['username']['header'] = function (Worksheet $sheet, $row, $column): int {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans('name'));
|
||||
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($columns['customer']) && !isset($columns['customer']['render'])) {
|
||||
@@ -423,7 +426,7 @@ abstract class AbstractSpreadsheetRenderer
|
||||
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));
|
||||
|
||||
$columns['timesheet-meta'] = [
|
||||
'header' => function (Worksheet $sheet, int $row, int $column) use ($timesheetMetaFields): int {
|
||||
'header' => function (Worksheet $sheet, $row, $column) use ($timesheetMetaFields): int {
|
||||
foreach ($timesheetMetaFields as $metaField) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
|
||||
}
|
||||
@@ -451,7 +454,7 @@ abstract class AbstractSpreadsheetRenderer
|
||||
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($customerQuery, CustomerMetaDisplayEvent::EXPORT));
|
||||
|
||||
$columns['customer-meta'] = [
|
||||
'header' => function (Worksheet $sheet, int $row, int $column) use ($customerMetaFields): int {
|
||||
'header' => function (Worksheet $sheet, $row, $column) use ($customerMetaFields): int {
|
||||
foreach ($customerMetaFields as $metaField) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
|
||||
}
|
||||
@@ -478,7 +481,7 @@ abstract class AbstractSpreadsheetRenderer
|
||||
if (isset($columns['project-meta'])) {
|
||||
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT));
|
||||
$columns['project-meta'] = [
|
||||
'header' => function (Worksheet $sheet, int $row, int $column) use ($projectMetaFields): int {
|
||||
'header' => function (Worksheet $sheet, $row, $column) use ($projectMetaFields): int {
|
||||
foreach ($projectMetaFields as $metaField) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
|
||||
}
|
||||
@@ -505,7 +508,7 @@ abstract class AbstractSpreadsheetRenderer
|
||||
if (isset($columns['activity-meta'])) {
|
||||
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT));
|
||||
$columns['activity-meta'] = [
|
||||
'header' => function (Worksheet $sheet, int $row, int $column) use ($activityMetaFields): int {
|
||||
'header' => function (Worksheet $sheet, $row, $column) use ($activityMetaFields): int {
|
||||
foreach ($activityMetaFields as $metaField) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
|
||||
}
|
||||
@@ -534,7 +537,7 @@ abstract class AbstractSpreadsheetRenderer
|
||||
$this->dispatcher->dispatch($event);
|
||||
$userPreferences = $event->getPreferences();
|
||||
$columns['user-meta'] = [
|
||||
'header' => function (Worksheet $sheet, int $row, int $column) use ($userPreferences): int {
|
||||
'header' => function (Worksheet $sheet, $row, $column) use ($userPreferences): int {
|
||||
foreach ($userPreferences as $metaField) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
|
||||
}
|
||||
@@ -572,7 +575,7 @@ abstract class AbstractSpreadsheetRenderer
|
||||
|
||||
if (isset($columns['customer_number'])) {
|
||||
if (!isset($columns['customer_number']['header'])) {
|
||||
$columns['customer_number']['header'] = function (Worksheet $sheet, int $row, int $column): int {
|
||||
$columns['customer_number']['header'] = function (Worksheet $sheet, $row, $column): int {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans('number'));
|
||||
|
||||
return 1;
|
||||
@@ -592,7 +595,7 @@ abstract class AbstractSpreadsheetRenderer
|
||||
|
||||
if (isset($columns['customer_vat']) && !isset($columns['customer_vat']['render'])) {
|
||||
if (!isset($columns['customer_vat']['header'])) {
|
||||
$columns['customer_vat']['header'] = function (Worksheet $sheet, int $row, int $column): int {
|
||||
$columns['customer_vat']['header'] = function (Worksheet $sheet, $row, $column): int {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans('vat_id'));
|
||||
|
||||
return 1;
|
||||
@@ -612,7 +615,7 @@ abstract class AbstractSpreadsheetRenderer
|
||||
|
||||
if (isset($columns['order_number']) && !isset($columns['order_number']['render'])) {
|
||||
if (!isset($columns['order_number']['header'])) {
|
||||
$columns['order_number']['header'] = function (Worksheet $sheet, int $row, int $column): int {
|
||||
$columns['order_number']['header'] = function (Worksheet $sheet, $row, $column): int {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans('orderNumber'));
|
||||
|
||||
return 1;
|
||||
@@ -672,7 +675,7 @@ abstract class AbstractSpreadsheetRenderer
|
||||
$amount = $settings['header']($sheet, $recordsHeaderRow, $recordsHeaderColumn);
|
||||
$recordsHeaderColumn += $amount;
|
||||
} else {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow), $this->translator->trans((\array_key_exists('label', $settings) && \is_string($settings['label'])) ? $settings['label'] : $label));
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow), $this->translator->trans($label));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,11 +767,11 @@ abstract class AbstractSpreadsheetRenderer
|
||||
abstract public function getFileExtension(): string;
|
||||
|
||||
/**
|
||||
* @param string $file
|
||||
* @param mixed $file
|
||||
* @param string $filename
|
||||
* @return BinaryFileResponse
|
||||
*/
|
||||
protected function getFileResponse(string $file, string $filename): BinaryFileResponse
|
||||
protected function getFileResponse($file, $filename): BinaryFileResponse
|
||||
{
|
||||
$response = new BinaryFileResponse($file);
|
||||
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
|
||||
|
||||
@@ -66,11 +66,12 @@ class XlsxRenderer extends AbstractSpreadsheetRenderer
|
||||
// Freeze first row and date & time columns for easier navigation
|
||||
$sheet->freezePane('D2');
|
||||
|
||||
foreach ($sheet->getColumnIterator() as $columnName => $column) {
|
||||
/** @var string $column */
|
||||
foreach (range('A', $highestColumn) as $column) {
|
||||
// We default to a reasonable auto-width decided by the client,
|
||||
// sadly ->getDefaultColumnDimension() is not supported so it needs
|
||||
// to be specific about what column should be auto sized.
|
||||
$col = $sheet->getColumnDimension($columnName);
|
||||
$col = $sheet->getColumnDimension($column);
|
||||
|
||||
// If no other width is specified (which defaults to -1)
|
||||
if ((int) $col->getWidth() === -1) {
|
||||
|
||||
@@ -11,9 +11,7 @@ namespace App\Export;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Repository\Query\ExportQuery;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||
|
||||
#[AutoconfigureTag]
|
||||
interface ExportRepositoryInterface
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
|
||||
namespace App\Export;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||
|
||||
#[AutoconfigureTag]
|
||||
interface RendererInterface extends ExportRendererInterface
|
||||
{
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user