Compare commits

..

3 Commits

Author SHA1 Message Date
Kevin Papst
b1cc5f2587 fix test 2023-10-31 16:58:47 +01:00
Kevin Papst
6b672a23b4 new "customer overview" listing report 2023-10-31 16:32:11 +01:00
Kevin Papst
900254b83a change default visibility 2023-10-31 16:30:01 +01:00
399 changed files with 4412 additions and 8845 deletions

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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 ....

View File

@@ -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

View File

@@ -19,7 +19,7 @@ jobs:
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
strategy:
matrix:
php: ['8.1', '8.2', '8.3']
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
View File

@@ -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 ###

View File

@@ -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/',
])

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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');

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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,
],
];

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -60,7 +60,6 @@ security:
logout:
path: logout
target: homepage
enable_csrf: true
login_throttling:
max_attempts: 5

View File

@@ -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

View File

@@ -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"

View File

@@ -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'");

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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"
}
}

View File

@@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,10 +3,10 @@
"app": {
"js": [
"/build/runtime.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"

View File

@@ -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"

View File

@@ -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);
}

View File

@@ -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'

View File

@@ -66,6 +66,7 @@ final class StatusController extends BaseApiController
{
$plugins = [];
foreach ($pluginManager->getPlugins() as $plugin) {
$pluginManager->loadMetadata($plugin);
$plugins[] = new Plugin($plugin);
}

View File

@@ -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);

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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));

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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
*/

View File

@@ -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
]);
}

View File

@@ -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;

View File

@@ -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
]);
}

View File

@@ -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),

View File

@@ -28,6 +28,7 @@ final class PluginController extends AbstractController
$installed = [];
$plugins = $manager->getPlugins();
foreach ($plugins as $plugin) {
$manager->loadMetadata($plugin);
$installed[] = $plugin->getId();
}

View File

@@ -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),
]
);
}

View File

@@ -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
]);
}

View File

@@ -0,0 +1,53 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller\Reporting;
use App\Controller\AbstractController;
use App\Customer\CustomerStatisticService;
use App\Reporting\CustomerView\CustomerViewForm;
use App\Reporting\CustomerView\CustomerViewQuery;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class CustomerViewController extends AbstractController
{
#[Route(path: '/reporting/customer_view', name: 'report_customer_view', methods: ['GET', 'POST'])]
#[IsGranted('report:customer')]
#[IsGranted(new Expression("is_granted('budget_any', 'customer')"))]
public function __invoke(Request $request, CustomerStatisticService $service): Response
{
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();
$query = new CustomerViewQuery($dateFactory->createDateTime(), $user);
$form = $this->createFormForGetRequest(CustomerViewForm::class, $query);
$form->submit($request->query->all(), false);
$customers = $service->findCustomersForView($query);
$entries = $service->getCustomerView($user, $customers, $query->getToday());
$byCustomer = [];
foreach ($entries as $entry) {
$customer = $entry->getCustomer();
$byCustomer[$customer->getId()] = $entry;
}
return $this->render('reporting/customer_view.html.twig', [
'entries' => $byCustomer,
'form' => $form->createView(),
'report_title' => 'report_customer_view',
'tableName' => 'customer_view_reporting',
'now' => $dateFactory->createDateTime(),
]);
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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()]);
}

View File

@@ -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']]);
}
}

View File

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

View File

@@ -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)]);
}

View File

@@ -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)]);
}

View File

@@ -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)]);
}

View File

@@ -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;
}

View File

@@ -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);
}
}
}

View File

@@ -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)
{
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -46,9 +46,4 @@ class Role
return $this;
}
public function isUser(): bool
{
return $this->name === User::ROLE_USER;
}
}

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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
{

View File

@@ -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;
}

View File

@@ -0,0 +1,57 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\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;
}
}

View File

@@ -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
{

View File

@@ -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;

View File

@@ -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';

View File

@@ -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()
*

View File

@@ -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',

View File

@@ -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']);
}

View File

@@ -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']);

View File

@@ -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()) {

View File

@@ -0,0 +1,61 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\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);
}
}
}

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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
{
/**

View File

@@ -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