Compare commits
27 Commits
docker-ips
...
2.26.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82a3b99a31 | ||
|
|
70741eebfd | ||
|
|
eb933e7021 | ||
|
|
372f3c8507 | ||
|
|
e030ff08db | ||
|
|
f13b81ede7 | ||
|
|
46c4449504 | ||
|
|
8b55cd3693 | ||
|
|
406ac7b9cb | ||
|
|
0c26a2678e | ||
|
|
49eb7068c9 | ||
|
|
dcc52f1a95 | ||
|
|
31bae44f3c | ||
|
|
84a0ca5ce6 | ||
|
|
9e736b26f6 | ||
|
|
32a1306394 | ||
|
|
96043afd6a | ||
|
|
255c7d77d6 | ||
|
|
e6d5a720bf | ||
|
|
ff9bf163ee | ||
|
|
5fac6a642c | ||
|
|
b1b81ee8a6 | ||
|
|
1ea64bf18e | ||
|
|
0c4fdda00a | ||
|
|
215bd6a01a | ||
|
|
63b8164926 | ||
|
|
8c26ff9aed |
@@ -8,7 +8,6 @@
|
||||
PassEnv DATABASE_URL
|
||||
PassEnv MAILER_URL
|
||||
PassEnv TRUSTED_PROXIES
|
||||
PassEnv TRUSTED_HOSTS
|
||||
|
||||
<Directory "/opt/kimai/public">
|
||||
Require all granted
|
||||
|
||||
@@ -13,20 +13,19 @@ try {
|
||||
]);
|
||||
} catch(\Exception $ex) {
|
||||
switch ($ex->getCode()) {
|
||||
// we can immediately stop startup here and show the error message
|
||||
case 1045:
|
||||
// we can immediately stop here and show the error message
|
||||
echo 'Access denied (1045)';
|
||||
die(1);
|
||||
// we can immediately stop startup here and show the error message
|
||||
case 1049:
|
||||
echo 'Unknown database (1049)';
|
||||
die(2);
|
||||
// error "Unknown database (1049)" can be ignored, the database will be created by Kimai
|
||||
return;
|
||||
// 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);
|
||||
// error "Unknown database (1049)" can be ignored, the database will be created by Kimai
|
||||
return;
|
||||
}
|
||||
switch ($ex->getMessage()) {
|
||||
// eg. no response (fw) - the startup script should retry it a couple of times
|
||||
|
||||
@@ -76,7 +76,6 @@ function handleStartup() {
|
||||
function prepareKimai() {
|
||||
# These are idempotent, so we can run them on every start-up
|
||||
/opt/kimai/bin/console -n kimai:install
|
||||
/opt/kimai/bin/console -n kimai:update
|
||||
if [ ! -z "$ADMINPASS" ] && [ ! -a "$ADMINMAIL" ]; then
|
||||
/opt/kimai/bin/console kimai:user:create admin "$ADMINMAIL" ROLE_SUPER_ADMIN "$ADMINPASS"
|
||||
fi
|
||||
18
.gitattributes
vendored
Normal file
18
.gitattributes
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
.docker export-ignore
|
||||
.github export-ignore
|
||||
assets export-ignore
|
||||
tests export-ignore
|
||||
.codecov.yml export-ignore
|
||||
.editorconfig export-ignore
|
||||
.eslintrc.js export-ignore
|
||||
.gitattributes export-ignore
|
||||
.gitignore export-ignore
|
||||
php-cs-fixer.dist.php export-ignore
|
||||
babel.config.js export-ignore
|
||||
package.json export-ignore
|
||||
php-cs-fixer.sh export-ignore
|
||||
phpstan.neon export-ignore
|
||||
phpstan.sh export-ignore
|
||||
phpunit.xml.dist export-ignore
|
||||
webpack.config.js export-ignore
|
||||
yarn.lock export-ignore
|
||||
27
.github/workflows/docker.yaml
vendored
27
.github/workflows/docker.yaml
vendored
@@ -1,6 +1,11 @@
|
||||
name: 'Docker Build'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
kimai_tag:
|
||||
description: 'Kimai tag to build'
|
||||
required: true
|
||||
release:
|
||||
types: [released]
|
||||
|
||||
@@ -13,6 +18,9 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -20,7 +28,24 @@ jobs:
|
||||
password: ${{secrets.DOCKERHUB_PASSWORD}}
|
||||
|
||||
- name: Determine version
|
||||
run: echo "kimai_version=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
|
||||
run: |
|
||||
input="${{ github.event.inputs.kimai_tag }}"
|
||||
|
||||
# Determine between manual trigger and release event
|
||||
if [ -z "$input" ]; then
|
||||
echo "Using release tag: ${{ github.event.release.tag_name }}"
|
||||
version="${{ github.event.release.tag_name }}"
|
||||
else
|
||||
echo "Using tag provided: $input"
|
||||
version="$input"
|
||||
fi
|
||||
|
||||
if [[ ! $version =~ ^2\.(0|[1-9]*)(0?)\.(0|[0-9]*)(0?)$ ]]; then
|
||||
echo "Invalid version number: $version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "kimai_version=$version" >> $GITHUB_ENV
|
||||
|
||||
- name: FPM image
|
||||
uses: docker/build-push-action@v5
|
||||
|
||||
2
.github/workflows/testing.yaml
vendored
2
.github/workflows/testing.yaml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
php: ['8.1', '8.2', '8.3', '8.4']
|
||||
|
||||
name: Integration (${{ matrix.php }})
|
||||
steps:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,6 +18,7 @@
|
||||
# for keeping empty directories
|
||||
/config/packages/local.yaml
|
||||
/config/bundles-local.php
|
||||
/var/dev/*
|
||||
/var/data/*
|
||||
/var/cache/*
|
||||
/var/invoices*
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
# Contributing
|
||||
|
||||
Kimai is an open source project, contributions made by the community are welcome.
|
||||
Kimai is an open source project, contributions made by the community are welcome.
|
||||
But we can only accept contributions with a signed CLA (Contributor License Agreement) to prevent issues in the future (you will see a link when opening a PR).
|
||||
|
||||
Send your ideas, code reviews, pull requests and feature requests to help to improve this project.
|
||||
|
||||
## Pull request rules
|
||||
|
||||
- Make your changes in a new git branch, based on the latest code in `main`
|
||||
- Apply our code-style by running `composer codestyle-fix`
|
||||
- Run the static code analysis with `composer phpstan`
|
||||
- Verify everything still works with `composer tests-unit` and `composer tests-integration`
|
||||
- Verify everything still works with `composer tests-unit`
|
||||
- Add tests for your changes
|
||||
- When sending in a PR, you must accept that your contributions/code will be published under MIT license (see the [LICENSE](LICENSE) file as well), otherwise your PR will be closed
|
||||
- If one of the PR checks/builds fails, fix it before asking for a review
|
||||
|
||||
Further documentation can be found in the [developer documentation](https://www.kimai.org/documentation/developers.html).
|
||||
|
||||
41
Dockerfile
41
Dockerfile
@@ -13,8 +13,8 @@
|
||||
#
|
||||
# docker build --no-cache -t kimai-fpm --build-arg BASE=fpm .
|
||||
# docker build --no-cache -t kimai-apache --build-arg BASE=apache .
|
||||
# docker run -d --name kimai-apache
|
||||
# docker exec -ti kimai-apache /bin/bash
|
||||
# docker run -d --name kimai-apache-app kimai-apache
|
||||
# docker exec -ti kimai-apache-app /bin/bash
|
||||
# ---------------------------------------------------------------------
|
||||
# Official PHP images: https://hub.docker.com/_/php/
|
||||
# https://github.com/docker-library/docs/blob/master/php/README.md#supported-tags-and-respective-dockerfile-links
|
||||
@@ -78,8 +78,8 @@ RUN apk add --no-cache \
|
||||
|
||||
# apache debian php extension base
|
||||
FROM php:8.3-apache-bookworm AS apache-php-ext-base
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y \
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
libldap2-dev \
|
||||
libicu-dev \
|
||||
libpng-dev \
|
||||
@@ -165,7 +165,8 @@ RUN apt-get update && \
|
||||
libpng16-16 \
|
||||
libzip4 \
|
||||
libxslt1.1 \
|
||||
libfreetype6 && \
|
||||
libfreetype6 \
|
||||
unzip && \
|
||||
echo "Listen 8001" > /etc/apache2/ports.conf && \
|
||||
a2enmod rewrite && \
|
||||
touch /use_apache
|
||||
@@ -217,20 +218,18 @@ COPY --from=php-ext-intl /usr/local/lib/php/extensions/no-debug-non-zts-20230831
|
||||
COPY --from=php-ext-opcache /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini
|
||||
|
||||
###########################
|
||||
# Shared tools
|
||||
# fetch Kimai sources
|
||||
###########################
|
||||
|
||||
# full kimai source
|
||||
FROM alpine:latest AS git-dev
|
||||
FROM alpine:latest AS git-prod
|
||||
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
|
||||
# the convention in the Kimai repository is: tags are always version numbers, branch names always start with a letter
|
||||
# if the KIMAI variable starts with a number (e.g. 2.24.0) we assume its a tag, otherwise its a branch
|
||||
RUN [[ $KIMAI =~ ^[0-9] ]] && export REF='tags' || export REF='heads' && \
|
||||
wget -O "/opt/kimai.tar.gz" "https://github.com/kimai/kimai/archive/refs/${REF}/${KIMAI}.tar.gz" && \
|
||||
tar -xpzf /opt/kimai.tar.gz -C /opt/ && \
|
||||
mv /opt/kimai-${KIMAI} /opt/kimai
|
||||
|
||||
###########################
|
||||
# global base build
|
||||
@@ -258,13 +257,12 @@ RUN ln -snf /usr/share/zoneinfo/${TIMEZONE} /etc/localtime && echo ${TIMEZONE} >
|
||||
|
||||
# copy startup script & DB checking script
|
||||
COPY .docker/dbtest.php /dbtest.php
|
||||
COPY .docker/startup.sh /startup.sh
|
||||
COPY .docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
ENV DATABASE_URL="mysql://kimai:kimai@127.0.0.1:3306/kimai?charset=utf8mb4&serverVersion=8.3"
|
||||
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=
|
||||
@@ -277,7 +275,7 @@ ENV COMPOSER_ALLOW_SUPERUSER=1
|
||||
|
||||
VOLUME [ "/opt/kimai/var" ]
|
||||
|
||||
CMD [ "/startup.sh" ]
|
||||
CMD [ "/entrypoint.sh" ]
|
||||
|
||||
###########################
|
||||
# final builds
|
||||
@@ -286,10 +284,9 @@ CMD [ "/startup.sh" ]
|
||||
# development build
|
||||
FROM base AS dev
|
||||
# copy kimai develop source
|
||||
COPY --from=git-dev --chown=www-data:www-data /opt/kimai /opt/kimai
|
||||
COPY --from=git-prod --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 && \
|
||||
@@ -305,7 +302,7 @@ ENV APP_ENV=dev
|
||||
ENV DATABASE_URL=
|
||||
ENV memory_limit=512M
|
||||
|
||||
# production build
|
||||
# the "prod" stage (production build) is configured as last stage in the file, as this is the default target in BuildKit
|
||||
FROM base AS prod
|
||||
# copy kimai production source
|
||||
COPY --from=git-prod --chown=www-data:www-data /opt/kimai /opt/kimai
|
||||
@@ -315,7 +312,7 @@ 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 && \
|
||||
composer --no-ansi require --update-no-dev --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 && \
|
||||
|
||||
@@ -7,5 +7,4 @@ As announced in the [README](README.md) security fixes will only be added to the
|
||||
| main branch | :white_check_mark: |
|
||||
| older releases | :x: |
|
||||
|
||||
|
||||
You find all information in our [Bughunter documentation](https://www.kimai.org/documentation/bughunter.html).
|
||||
|
||||
0
assets/.gitignore
vendored
0
assets/.gitignore
vendored
@@ -48,10 +48,10 @@ export default class KimaiCopyDataForm extends KimaiFormPlugin {
|
||||
if (element.dataset.event !== undefined) {
|
||||
for (const event of element.dataset.event.split(' ')) {
|
||||
target.dispatchEvent(new Event(event));
|
||||
}
|
||||
} else if (element.dataset.eventBubbles !== undefined) {
|
||||
for (const event of element.dataset.eventBubbles.split(' ')) {
|
||||
target.dispatchEvent(new Event(event, {bubbles: true}));
|
||||
const form = target.closest('form');
|
||||
if (form !== null) {
|
||||
form.dispatchEvent(new Event(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
@@ -236,8 +236,13 @@ export default class KimaiFormSelect extends KimaiFormTomselectPlugin {
|
||||
options.push(optGroup);
|
||||
}
|
||||
|
||||
// log the one with a group name first (e.g. non-global activities)
|
||||
options.forEach(child => node.appendChild(child));
|
||||
emptyOpts.forEach(child => node.appendChild(child));
|
||||
|
||||
// append the ones with no parent at the end (e.g. global activities)
|
||||
const optGroupEmpty = this._createOptgroup('');
|
||||
emptyOpts.forEach(child => optGroupEmpty.appendChild(child));
|
||||
node.appendChild(optGroupEmpty)
|
||||
|
||||
// if available, re-select the previous selected option (mostly usable for global activities)
|
||||
node.value = selectedValue;
|
||||
|
||||
@@ -15,7 +15,7 @@ export default class KimaiMultiUpdateTable extends KimaiPlugin {
|
||||
|
||||
init()
|
||||
{
|
||||
if (document.getElementById('multi_update_all') === null) {
|
||||
if (document.getElementsByClassName('multi_update_all').length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -23,64 +23,64 @@ export default class KimaiMultiUpdateTable extends KimaiPlugin {
|
||||
// via KimaiDatable and everything inside will be removed, including event listeners
|
||||
const element = document.querySelector('div.page-body');
|
||||
element.addEventListener('change', (event) => {
|
||||
if (event.target.matches('#multi_update_all')) {
|
||||
if (event.target.matches('.multi_update_all')) {
|
||||
// the "check all" checkbox in the upper start corner of the table
|
||||
const checked = event.target.checked;
|
||||
for (const element of document.querySelectorAll('.multi_update_single')) {
|
||||
const table = event.target.closest('table');
|
||||
for (const element of table.querySelectorAll('.multi_update_single')) {
|
||||
element.checked = checked;
|
||||
}
|
||||
this._toggleForm();
|
||||
this._toggleForm(table);
|
||||
event.stopPropagation();
|
||||
} else if (event.target.matches('.multi_update_single')) {
|
||||
// single checkboxes in front of each row
|
||||
this._toggleForm();
|
||||
this._toggleForm(event.target.closest('table'));
|
||||
event.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
element.addEventListener('click', (event) => {
|
||||
if (event.target.matches('.multi_update_table_action')) {
|
||||
const selectedItem = event.target;
|
||||
const ids = this._getSelectedIds();
|
||||
const form = document.getElementById('multi_update_form');
|
||||
const question = form.dataset['question'].replace(/%action%/, selectedItem.textContent).replace(/%count%/, ids.length.toString());
|
||||
const selectedButton = event.target;
|
||||
const form = selectedButton.form;
|
||||
const ids = form.querySelector('.multi_update_ids').value.split(',');
|
||||
const question = form.dataset['question'].replace(/%action%/, selectedButton.textContent).replace(/%count%/, ids.length.toString());
|
||||
|
||||
/** @type {KimaiAlert} ALERT */
|
||||
const ALERT = this.getPlugin('alert');
|
||||
ALERT.question(question, function(value) {
|
||||
if (value) {
|
||||
const form = document.getElementById('multi_update_form');
|
||||
form.action = selectedItem.dataset['href'];
|
||||
form.action = selectedButton.dataset['href'];
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_getSelectedIds()
|
||||
|
||||
/**
|
||||
* @param {HTMLTableElement} table
|
||||
* @private
|
||||
*/
|
||||
_toggleForm(table)
|
||||
{
|
||||
const card = table.closest('div.card.data_table')
|
||||
|
||||
let ids = [];
|
||||
for (const box of document.querySelectorAll('input.multi_update_single:checked')) {
|
||||
for (const box of table.querySelectorAll('input.multi_update_single:checked')) {
|
||||
ids.push(box.value);
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
_toggleForm()
|
||||
{
|
||||
const ids = this._getSelectedIds();
|
||||
document.getElementById('multi_update_table_entities').value = ids.join(',');
|
||||
card.querySelector('.multi_update_ids').value = ids.join(',');
|
||||
|
||||
if (ids.length > 0) {
|
||||
for (const element of document.getElementsByClassName('multi_update_form_hide')) {
|
||||
for (const element of card.querySelectorAll('.multi_update_form_hide')) {
|
||||
element.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
document.getElementById('multi_update_form').style.display = null;//'block';
|
||||
card.querySelector('form.multi_update_form').style.display = null;//'block';
|
||||
} else {
|
||||
document.getElementById('multi_update_form').style.setProperty('display', 'none', 'important');
|
||||
for (const element of document.getElementsByClassName('multi_update_form_hide')) {
|
||||
card.querySelector('form.multi_update_form').style.setProperty('display', 'none', 'important');
|
||||
for (const element of card.querySelectorAll('.multi_update_form_hide')) {
|
||||
element.style.display = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,111 +5,30 @@
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
font-size: 10pt;
|
||||
margin: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
table, tr, td, th, p, h1, h2, h3, h4, h5 {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
th {
|
||||
border: none;
|
||||
text-align: left;
|
||||
}
|
||||
td {
|
||||
vertical-align: top;
|
||||
}
|
||||
p {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.text-small {
|
||||
font-size: 7pt;
|
||||
}
|
||||
.padding-left {
|
||||
padding-left: 15px;
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.text-left, .text-begin {
|
||||
text-align: left;
|
||||
}
|
||||
.text-right, .text-end {
|
||||
text-align: right;
|
||||
}
|
||||
.text-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.date {
|
||||
font-size: 80%;
|
||||
text-align: right;
|
||||
padding-top: 6px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
}
|
||||
table.addresses, p {
|
||||
line-height: 14pt;
|
||||
}
|
||||
/* The address table */
|
||||
table.addresses {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
/* The PDF header */
|
||||
table.header {
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #999;
|
||||
}
|
||||
table.header .title {
|
||||
font-weight: normal;
|
||||
font-size: 140%;
|
||||
}
|
||||
/* The PDF footer */
|
||||
table.footer {
|
||||
border-top: 1px solid #999;
|
||||
font-size: 80%;
|
||||
padding-top: 8px;
|
||||
line-height: 14px;
|
||||
}
|
||||
table.footer td {
|
||||
vertical-align: top;
|
||||
}
|
||||
/* The invoice items list */
|
||||
table.items {
|
||||
border-collapse: collapse;
|
||||
margin-top: 50px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
table.items thead th,
|
||||
table.items tbody td {
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
table.items thead th.first,
|
||||
table.items tbody td.first {
|
||||
padding-left: 0;
|
||||
}
|
||||
table.items thead th.last,
|
||||
table.items tbody td.last,
|
||||
table.items tfoot td.last {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
table.items td,
|
||||
table.items th {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
table.items tfoot td,
|
||||
table.items tfoot th {
|
||||
padding: 20px 0 0 0;
|
||||
line-height: 1px;
|
||||
}
|
||||
table.items tfoot td.last {
|
||||
padding-left: 10px;
|
||||
}
|
||||
table.items tr.odd {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
body { font-family: sans-serif; font-size: 10pt; margin: 4px; padding: 0; }
|
||||
table, tr, td, th, p, h1, h2, h3, h4, h5 { padding: 0; margin: 0;}
|
||||
th { border: none; text-align: left; }
|
||||
td { vertical-align: top; }
|
||||
p { margin-bottom: 12px; }
|
||||
.text-small { font-size: 7pt; }
|
||||
.padding-left { padding-left: 15px; }
|
||||
.text-center { text-align: center; }
|
||||
.text-left, .text-begin { text-align: left; }
|
||||
.text-right, .text-end { text-align: right; }
|
||||
.text-nowrap { white-space: nowrap; }
|
||||
.date { font-size: 80%; text-align: right; padding-top: 6px; }
|
||||
table { width: 100%; border-spacing: 0; }
|
||||
table.addresses, p { line-height: 14pt; }
|
||||
table.addresses { margin-bottom: 30px; }
|
||||
table.header { padding-bottom: 4px; border-bottom: 1px solid #999; }
|
||||
table.header .title { font-weight: normal; font-size: 140%; }
|
||||
table.footer { border-top: 1px solid #999; font-size: 80%; padding-top: 8px; line-height: 14px; }
|
||||
table.footer td { vertical-align: top; }
|
||||
table.items { border-collapse: collapse; margin-top: 50px; margin-bottom: 60px; }
|
||||
table.items thead th, table.items tbody td { border-bottom: 1px solid #ccc; }
|
||||
table.items thead th.first, table.items tbody td.first { padding-left: 0; }
|
||||
table.items thead th.last, table.items tbody td.last, table.items tfoot td.last { padding-right: 0; }
|
||||
table.items td, table.items th { padding: 8px 6px; }
|
||||
table.items tfoot td, table.items tfoot th { padding: 20px 0 0 0; line-height: 1px; }
|
||||
table.items tfoot td.last { padding-left: 10px; }
|
||||
table.items tr.odd { background-color: #f5f5f5; }
|
||||
|
||||
@@ -41,4 +41,22 @@ fieldset:empty {
|
||||
.nav-pills .nav-link.active {
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] {
|
||||
/* fixes https://github.com/tabler/tabler/issues/1974 */
|
||||
.litepicker .container__days .day-item.is-in-range {
|
||||
--litepicker-is-in-range-color: var(--tblr-primary-text-emphasis);
|
||||
}
|
||||
}
|
||||
|
||||
/* fixes contrast of batch-update checkboxes - https://github.com/kimai/kimai/issues/5146 */
|
||||
.multiupdater[type=checkbox]
|
||||
{
|
||||
--tblr-border-color-translucent: rgba(4, 32, 69, .2);
|
||||
}
|
||||
|
||||
/* fixes height for long tags https://github.com/kimai/kimai/issues/5169 */
|
||||
.tag {
|
||||
--tblr-tag-height: unset;
|
||||
}
|
||||
@@ -98,7 +98,7 @@ table.dataTable {
|
||||
width: 15px;
|
||||
}
|
||||
&.overlapping {
|
||||
border-top: 2px solid rgba(214,57,57,.1);
|
||||
border-top: 2px solid rgba(214,57,57,.2);
|
||||
}
|
||||
&.exported {
|
||||
opacity: 0.7;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "8.1.*||8.2.*||8.3.*",
|
||||
"php": "8.1.*||8.2.*||8.3.*||8.4.*",
|
||||
"ext-gd": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-json": "*",
|
||||
@@ -61,6 +61,7 @@
|
||||
"symfony/mailer": "^6.0",
|
||||
"symfony/mime": "^6.0",
|
||||
"symfony/monolog-bundle": "^3.4",
|
||||
"symfony/process": "^6.0",
|
||||
"symfony/rate-limiter": "^6.0",
|
||||
"symfony/runtime": "^6.0",
|
||||
"symfony/security-bundle": "^6.0",
|
||||
@@ -83,12 +84,12 @@
|
||||
"doctrine/doctrine-fixtures-bundle": "^3.2",
|
||||
"fakerphp/faker": "^1.15",
|
||||
"friendsofphp/php-cs-fixer": "^3.3",
|
||||
"phpstan/phpstan": "^1.0",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.0",
|
||||
"phpstan/phpstan-doctrine": "^1.0",
|
||||
"phpstan/phpstan-phpunit": "^1.0",
|
||||
"phpstan/phpstan-strict-rules": "^1.0",
|
||||
"phpstan/phpstan-symfony": "^1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0",
|
||||
"phpstan/phpstan-doctrine": "^2.0",
|
||||
"phpstan/phpstan-phpunit": "^2.0",
|
||||
"phpstan/phpstan-strict-rules": "^2.0",
|
||||
"phpstan/phpstan-symfony": "^2.0",
|
||||
"phpunit/phpunit": "9.5.*",
|
||||
"symfony/browser-kit": "^6.0",
|
||||
"symfony/css-selector": "^6.0",
|
||||
@@ -134,6 +135,10 @@
|
||||
"symfony/polyfill-ctype": "*",
|
||||
"symfony/polyfill-mbstring": "*",
|
||||
"symfony/polyfill-intl": "*",
|
||||
"symfony/polyfill-intl-icu": "*",
|
||||
"symfony/polyfill-intl-grapheme": "*",
|
||||
"symfony/polyfill-intl-idn": "*",
|
||||
"symfony/polyfill-intl-normalizer": "*",
|
||||
"symfony/polyfill-iconv": "*",
|
||||
"symfony/polyfill-php81": "*",
|
||||
"symfony/polyfill-php80": "*",
|
||||
|
||||
1841
composer.lock
generated
1841
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,5 @@ twig:
|
||||
- 'form/vertical.html.twig'
|
||||
exception_controller: null
|
||||
paths:
|
||||
# used in templates/emails/layout.html.twig
|
||||
'%kernel.project_dir%/assets/css': css
|
||||
'%kernel.project_dir%/templates/bundles/TablerBundle': theme
|
||||
'%kernel.project_dir%/vendor/kevinpapst/tabler-bundle/templates': theme
|
||||
|
||||
116
kimai.sh
Executable file
116
kimai.sh
Executable file
@@ -0,0 +1,116 @@
|
||||
#!/bin/bash
|
||||
|
||||
# --------------------------------------------------------------------------------
|
||||
# This script was added with 2.24.0 and is in BETA status.
|
||||
#
|
||||
# To improve this script across platforms I need your feedback!
|
||||
# --------------------------------------------------------------------------------
|
||||
|
||||
function update_kimai() {
|
||||
if [[ "$1" =~ ^([0-9]+\.){2,3}[0-9]+$ ]]; then
|
||||
export VERSION=$1
|
||||
else
|
||||
echo "You need to supply a full Kimai version like: \"2.24.0\""
|
||||
exit 1
|
||||
fi
|
||||
git checkout -- composer.json
|
||||
git checkout -- composer.lock
|
||||
git checkout -- symfony.lock
|
||||
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo "Cannot update: file changes detected. Run \"git status\" for details."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf var/cache/* 2>&1
|
||||
git fetch --tags
|
||||
git checkout "$VERSION"
|
||||
$KIMAI_PHP "$KIMAI_COMPOSER" install --no-dev --optimize-autoloader
|
||||
|
||||
$KIMAI_PHP bin/console kimai:install
|
||||
|
||||
install_plugins
|
||||
|
||||
if [[ -z "${KIMAI_NO_PERMS}" ]]; then
|
||||
set_permission
|
||||
fi
|
||||
}
|
||||
|
||||
function install_plugins() {
|
||||
# detect if there are additional plugins that we need to install
|
||||
packages="$($PHP bin/console kimai:plugin --composer)"
|
||||
export PACKAGES=$packages
|
||||
if [ -n "$PACKAGES" ]; then
|
||||
$KIMAI_PHP "$KIMAI_COMPOSER" require "$PACKAGES"
|
||||
$KIMAI_PHP bin/console kimai:plugins --install
|
||||
fi
|
||||
}
|
||||
|
||||
function set_permission() {
|
||||
chown -R "$KIMAI_USER":"$KIMAI_GROUP" .
|
||||
chmod -R g+r .
|
||||
chmod -R g+rw var/
|
||||
}
|
||||
|
||||
if [[ -z "${KIMAI_USER}" ]]; then
|
||||
export KIMAI_USER=""
|
||||
fi
|
||||
|
||||
if [[ -z "${KIMAI_GROUP}" ]]; then
|
||||
export KIMAI_GROUP="www-data"
|
||||
fi
|
||||
|
||||
if [[ -z "${KIMAI_PHP}" ]]; then
|
||||
export KIMAI_PHP="php"
|
||||
fi
|
||||
|
||||
if [[ -z "${KIMAI_COMPOSER}" ]]; then
|
||||
export KIMAI_COMPOSER="composer"
|
||||
fi
|
||||
|
||||
cd "$(dirname "$0")" || { echo "Cannot change working directory."; exit 1; }
|
||||
|
||||
# we need a few commands installed in order for this script to complete
|
||||
command -v $KIMAI_COMPOSER >/dev/null 2>&1 || { echo >&2 "Update requires 'composer' but it's not installed."; exit 1; }
|
||||
command -v git >/dev/null 2>&1 || { echo >&2 "Update requires 'git' but it's not installed."; exit 1; }
|
||||
command -v $KIMAI_PHP >/dev/null 2>&1 || { echo >&2 "Update requires 'php' but it's not installed."; exit 1; }
|
||||
|
||||
if [[ -n $1 ]]; then
|
||||
if [ "$1" == 'update' ]; then
|
||||
update_kimai "$2"
|
||||
exit
|
||||
elif [ "$1" == 'permission' ]; then
|
||||
set_permission
|
||||
exit
|
||||
elif [ "$1" == 'plugins' ]; then
|
||||
install_plugins
|
||||
exit
|
||||
else
|
||||
echo ""
|
||||
echo ">> Unknown command: $1"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "This script has the following sub-commands:"
|
||||
echo ""
|
||||
echo "$0 update <version> - Install Kimai version <version>"
|
||||
echo "$0 permission - Fix file permissions"
|
||||
echo "$0 plugins - Install plugins from var/packages/*.zip"
|
||||
echo ""
|
||||
echo "Use the following environment variables to customize the runtime:"
|
||||
echo ""
|
||||
echo "KIMAI_USER - Username of the webserver/php process that needs write access"
|
||||
echo "KIMAI_GROUP - Group of the webserver/php process that needs write access"
|
||||
echo "KIMAI_PHP - Full path to PHP executable in the correct version"
|
||||
echo "KIMAI_COMPOSER - Path to composer executable or .phar file"
|
||||
echo "KIMAI_NO_PERMS - Skip changing permissions"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo ""
|
||||
echo "$0 2.24.0"
|
||||
echo "KIMAI_PHP=/usr/bin/php8.3 $0 2.24.0"
|
||||
echo "KIMAI_PHP=/usr/bin/php8.3 KIMAI_COMPOSER=/tmp/composer.phar $0 2.24.0"
|
||||
echo "KIMAI_PHP=php8.3 KIMAI_GROUP=httpd $0 2.24.0"
|
||||
echo "KIMAI_NO_PERMS=1 KIMAI_PHP=/usr/bin/php8.3 $0 2.24.0"
|
||||
echo ""
|
||||
@@ -24,9 +24,9 @@
|
||||
],
|
||||
"packageManager": "yarn@3.2.1",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.19",
|
||||
"@babel/core": "^7.26",
|
||||
"@babel/eslint-parser": "^7.19",
|
||||
"@babel/preset-env": "^7.19",
|
||||
"@babel/preset-env": "^7.26",
|
||||
"@fortawesome/fontawesome-free": "^6.5",
|
||||
"@fullcalendar/bootstrap5": "^5.11",
|
||||
"@fullcalendar/core": "^5.11",
|
||||
@@ -47,7 +47,10 @@
|
||||
"sass": "^1.62",
|
||||
"sass-loader": "^13.2",
|
||||
"tom-select": "^2.2",
|
||||
"webpack": "^5.91",
|
||||
"webpack": "^5.96",
|
||||
"webpack-cli": "^4.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
141
phpstan.neon
141
phpstan.neon
@@ -21,12 +21,10 @@ parameters:
|
||||
booleansInConditions: false
|
||||
uselessCast: true
|
||||
requireParentConstructorCall: true
|
||||
disallowedConstructs: false
|
||||
overwriteVariablesWithLoop: true
|
||||
closureUsesThis: true
|
||||
matchingInheritedMethodNames: true
|
||||
numericOperandsInArithmeticOperators: true
|
||||
strictCalls: false
|
||||
switchConditionsMatchingType: true
|
||||
noVariableVariables: false
|
||||
paths:
|
||||
@@ -290,7 +288,7 @@ parameters:
|
||||
path: src/Command/ExportCreateCommand.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$locale of static method Symfony\\\\Polyfill\\\\Intl\\\\Icu\\\\Locale\\:\\:setDefault\\(\\) expects string, mixed given\\.$#"
|
||||
message: "#^Parameter \\#1 \\$locale of static method Locale\\:\\:setDefault\\(\\) expects string, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Command/ExportCreateCommand.php
|
||||
|
||||
@@ -315,7 +313,7 @@ parameters:
|
||||
path: src/Command/ExportCreateCommand.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$name of method Symfony\\\\Component\\\\HttpFoundation\\\\File\\\\File\\:\\:move\\(\\) expects string\\|null, array\\<int, string\\>\\|string given\\.$#"
|
||||
message: "#^Parameter \\#2 \\$name of method Symfony\\\\Component\\\\HttpFoundation\\\\File\\\\File\\:\\:move\\(\\) expects string\\|null, list\\<string\\>\\|string given\\.$#"
|
||||
count: 1
|
||||
path: src/Command/ExportCreateCommand.php
|
||||
|
||||
@@ -329,16 +327,6 @@ parameters:
|
||||
count: 4
|
||||
path: src/Command/InstallCommand.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method get\\(\\) on Symfony\\\\Component\\\\Console\\\\Helper\\\\HelperSet\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Command/InstallCommand.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Command\\\\InstallCommand\\:\\:askConfirmation\\(\\) should return bool but returns mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Command/InstallCommand.php
|
||||
|
||||
-
|
||||
message: "#^Binary operation \"\\.\" between non\\-empty\\-string and non\\-empty\\-list\\<string\\>\\|string results in an error\\.$#"
|
||||
count: 1
|
||||
@@ -469,43 +457,13 @@ parameters:
|
||||
count: 1
|
||||
path: src/Command/ResetTestCommand.php
|
||||
|
||||
-
|
||||
message: "#^Strict comparison using \\!\\=\\= between '\\$2y\\$13\\$X8…' and null will always evaluate to true\\.$#"
|
||||
count: 1
|
||||
path: src/Command/ResetTestCommand.php
|
||||
|
||||
-
|
||||
message: "#^Strict comparison using \\!\\=\\= between 'Administrator'\\|'CFO'\\|'Developer'\\|'Developer \\(left…'\\|'Head of Development'\\|'Quality Tester 1'\\|'Quality Tester 2'\\|'Super Administrator' and null will always evaluate to true\\.$#"
|
||||
count: 1
|
||||
path: src/Command/ResetTestCommand.php
|
||||
|
||||
-
|
||||
message: "#^Strict comparison using \\!\\=\\= between 'anna_admin'\\|'chris_user'\\|'clara_customer'\\|'john_user'\\|'susan_super'\\|'test_user_1'\\|'test_user_2'\\|'tony_teamlead' and null will always evaluate to true\\.$#"
|
||||
count: 1
|
||||
path: src/Command/ResetTestCommand.php
|
||||
|
||||
-
|
||||
message: "#^Strict comparison using \\!\\=\\= between 'anna_admin@example…'\\|'chris_user@example…'\\|'clara_customer…'\\|'john_user@example…'\\|'susan_super@example…'\\|'test_user_1@example…'\\|'test_user_2@example…'\\|'tony_teamlead…' and null will always evaluate to true\\.$#"
|
||||
count: 2
|
||||
path: src/Command/ResetTestCommand.php
|
||||
|
||||
-
|
||||
message: "#^Strict comparison using \\!\\=\\= between 0\\|1 and null will always evaluate to true\\.$#"
|
||||
count: 1
|
||||
path: src/Command/ResetTestCommand.php
|
||||
|
||||
-
|
||||
message: "#^Strict comparison using \\!\\=\\= between array\\{\\}\\|array\\{'ROLE_ADMIN'\\}\\|array\\{'ROLE_CUSTOMER'\\}\\|array\\{'ROLE_SUPER_ADMIN'\\}\\|array\\{'ROLE_TEAMLEAD'\\} and null will always evaluate to true\\.$#"
|
||||
count: 1
|
||||
path: src/Command/ResetTestCommand.php
|
||||
|
||||
-
|
||||
message: "#^Access to an undefined property SimpleXMLElement\\|false\\:\\:\\$file\\.$#"
|
||||
count: 1
|
||||
path: src/Command/TranslationCommand.php
|
||||
|
||||
-
|
||||
message: "#^Argument of an invalid type array\\<int, string\\>\\|false supplied for foreach, only iterables are supported\\.$#"
|
||||
message: "#^Argument of an invalid type list\\<string\\>\\|false supplied for foreach, only iterables are supported\\.$#"
|
||||
count: 4
|
||||
path: src/Command/TranslationCommand.php
|
||||
|
||||
@@ -584,11 +542,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Command/TranslationCommand.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method find\\(\\) on Symfony\\\\Component\\\\Console\\\\Application\\|null\\.$#"
|
||||
count: 3
|
||||
path: src/Command/UpdateCommand.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Configuration\\\\LdapConfiguration\\:\\:getConnectionParameters\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -744,11 +697,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Controller/ActivityController.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$returnTo of method OneLogin\\\\Saml2\\\\Auth\\:\\:login\\(\\) expects string\\|null, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Controller/Auth/SamlController.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'user' on mixed\\.$#"
|
||||
count: 1
|
||||
@@ -1114,16 +1062,6 @@ parameters:
|
||||
count: 2
|
||||
path: src/DataFixtures/TimesheetFixtures.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\DataFixtures\\\\UserFixtures\\:\\:getUserDefinition\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/DataFixtures/UserFixtures.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\DataFixtures\\\\UserFixtures\\:\\:getUserPreferences\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/DataFixtures/UserFixtures.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\DependencyInjection\\\\AppExtension\\:\\:createPermissionParameter\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -1349,16 +1287,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Entity/Team.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Entity\\\\Team\\:\\:getTeamleads\\(\\) should return array\\<App\\\\Entity\\\\User\\> but returns array\\<int, App\\\\Entity\\\\User\\|null\\>\\.$#"
|
||||
count: 1
|
||||
path: src/Entity/Team.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Entity\\\\Team\\:\\:getUsers\\(\\) should return array\\<App\\\\Entity\\\\User\\> but returns array\\<int, App\\\\Entity\\\\User\\|null\\>\\.$#"
|
||||
count: 1
|
||||
path: src/Entity/Team.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\Entity\\\\Team\\:\\:\\$activities with generic interface Doctrine\\\\Common\\\\Collections\\\\Collection does not specify its types\\: TKey, T$#"
|
||||
count: 1
|
||||
@@ -1520,7 +1448,7 @@ parameters:
|
||||
path: src/Entity/UserPreference.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, string\\|null given\\.$#"
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Entity/UserPreference.php
|
||||
|
||||
@@ -1534,11 +1462,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Entity/UserPreference.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\Entity\\\\UserPreference\\:\\:\\$value \\(string\\|null\\) does not accept mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Entity/UserPreference.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Event\\\\AbstractTimesheetMultipleEvent\\:\\:getTimesheets\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -1864,11 +1787,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Export/Spreadsheet/EntityWithMetaFieldsExporter.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Export\\\\Spreadsheet\\\\Extractor\\\\AnnotationExtractor\\:\\:extract\\(\\) should return array\\<App\\\\Export\\\\Spreadsheet\\\\ColumnDefinition\\> but returns array\\<int, App\\\\Export\\\\Spreadsheet\\\\ColumnDefinition\\|null\\>\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Spreadsheet/Extractor/AnnotationExtractor.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$objectOrClass of class ReflectionClass constructor expects class\\-string\\<T of object\\>\\|T of object, string given\\.$#"
|
||||
count: 1
|
||||
@@ -2060,7 +1978,7 @@ parameters:
|
||||
path: src/Form/Helper/ActivityHelper.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, bool\\|float\\|int\\|string given\\.$#"
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, bool\\|float\\|int\\|string given\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Helper/ActivityHelper.php
|
||||
|
||||
@@ -2075,7 +1993,7 @@ parameters:
|
||||
path: src/Form/Helper/CustomerHelper.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, bool\\|float\\|int\\|string given\\.$#"
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, bool\\|float\\|int\\|string given\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Helper/CustomerHelper.php
|
||||
|
||||
@@ -2090,7 +2008,7 @@ parameters:
|
||||
path: src/Form/Helper/ProjectHelper.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, bool\\|float\\|int\\|string given\\.$#"
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, bool\\|float\\|int\\|string given\\.$#"
|
||||
count: 1
|
||||
path: src/Form/Helper/ProjectHelper.php
|
||||
|
||||
@@ -3420,7 +3338,7 @@ parameters:
|
||||
path: src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, mixed given\\.$#"
|
||||
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\<string\\>\\|string, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
|
||||
|
||||
@@ -3450,7 +3368,7 @@ parameters:
|
||||
path: src/Invoice/Renderer/DocxRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#3 \\$subject of function preg_replace expects array\\|string, mixed given\\.$#"
|
||||
message: "#^Parameter \\#3 \\$subject of function preg_replace expects array\\<float\\|int\\|string\\>\\|string, mixed given\\.$#"
|
||||
count: 2
|
||||
path: src/Invoice/Renderer/DocxRenderer.php
|
||||
|
||||
@@ -3614,11 +3532,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Ldap/LdapUserProvider.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\.\\.\\.\\$addresses of method Symfony\\\\Component\\\\Mime\\\\Email\\:\\:from\\(\\) expects string\\|Symfony\\\\Component\\\\Mime\\\\Address, string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Mail/KimaiMailer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\.\\.\\.\\$arrays of function array_merge expects array, mixed given\\.$#"
|
||||
count: 1
|
||||
@@ -3634,11 +3547,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Model/MonthlyStatistic.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Model\\\\MonthlyStatistic\\:\\:getYears\\(\\) should return array\\<string\\> but returns array\\<int, int\\|string\\>\\.$#"
|
||||
count: 1
|
||||
path: src/Model/MonthlyStatistic.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Model\\\\Statistic\\\\Day\\:\\:getDetails\\(\\) return type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
@@ -3709,11 +3617,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Plugin/PluginMetadata.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$array of function array_key_exists expects array, mixed given\\.$#"
|
||||
count: 1
|
||||
path: src/Plugin/PluginMetadata.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'duration' on mixed\\.$#"
|
||||
count: 3
|
||||
@@ -3964,11 +3867,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Repository/TimesheetRepository.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Repository\\\\TimesheetRepository\\:\\:getRawData\\(\\) should return array but returns mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Repository/TimesheetRepository.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$amountThisMonth of method App\\\\Model\\\\TimesheetStatistic\\:\\:setAmountThisMonth\\(\\) expects float\\|int, mixed given\\.$#"
|
||||
count: 1
|
||||
@@ -4064,11 +3962,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Saml/Security/SamlAuthenticationFailureHandler.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Saml\\\\Security\\\\SamlAuthenticationSuccessHandler\\:\\:determineTargetUrl\\(\\) should return string but returns mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Saml/Security/SamlAuthenticationSuccessHandler.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\Saml\\\\Security\\\\SamlAuthenticationSuccessHandler\\:\\:\\$defaultOptions has no type specified\\.$#"
|
||||
count: 1
|
||||
@@ -4510,7 +4403,7 @@ parameters:
|
||||
path: src/Utils/LocaleFormatter.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Utils\\\\LocaleFormatter\\:\\:durationDecimal\\(\\) should return string but returns bool\\|string\\.$#"
|
||||
message: "#^Method App\\\\Utils\\\\LocaleFormatter\\:\\:durationDecimal\\(\\) should return string but returns string\\|false\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/LocaleFormatter.php
|
||||
|
||||
@@ -4520,17 +4413,17 @@ parameters:
|
||||
path: src/Utils/LocaleFormatter.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Utils\\\\LocaleFormatter\\:\\:money\\(\\) should return string but returns bool\\|string\\.$#"
|
||||
message: "#^Method App\\\\Utils\\\\LocaleFormatter\\:\\:money\\(\\) should return string but returns string\\|false\\.$#"
|
||||
count: 2
|
||||
path: src/Utils/LocaleFormatter.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$num of method NumberFormatter\\:\\:format\\(\\) expects float\\|int, float\\|int\\|string given\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/LocaleFormatter.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$num of method Symfony\\\\Polyfill\\\\Intl\\\\Icu\\\\NumberFormatter\\:\\:format\\(\\) expects float\\|int, float\\|int\\|string given\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/LocaleFormatter.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$currency of method Symfony\\\\Polyfill\\\\Intl\\\\Icu\\\\NumberFormatter\\:\\:formatCurrency\\(\\) expects string, string\\|null given\\.$#"
|
||||
message: "#^Parameter \\#2 \\$currency of method NumberFormatter\\:\\:formatCurrency\\(\\) expects string, string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Utils/LocaleFormatter.php
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
public/build/app.dd395c1d.js
Normal file
2
public/build/app.dd395c1d.js
Normal file
File diff suppressed because one or more lines are too long
@@ -3,10 +3,10 @@
|
||||
"app": {
|
||||
"js": [
|
||||
"/build/runtime.74179306.js",
|
||||
"/build/app.3463e2a4.js"
|
||||
"/build/app.dd395c1d.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app.75450933.css"
|
||||
"/build/app.6bd98062.css"
|
||||
]
|
||||
},
|
||||
"app-rtl": {
|
||||
@@ -15,7 +15,7 @@
|
||||
"/build/app-rtl.97153087.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app-rtl.dbee41f9.css"
|
||||
"/build/app-rtl.6a14a027.css"
|
||||
]
|
||||
},
|
||||
"export-pdf": {
|
||||
@@ -72,10 +72,10 @@
|
||||
},
|
||||
"integrity": {
|
||||
"/build/runtime.74179306.js": "sha384-OC1hTNUXUalKJcvmzrZ0TMCOIwnhxCxgG9dQkbcwR7WcBBCkl2H8bs3giiT2pAwG",
|
||||
"/build/app.3463e2a4.js": "sha384-8Kq7fZHUdtxWd3CVdPUapwwO8qjw3/N2mP4w14TczsDI3jQ3WlJZeUyL0WPZP8QD",
|
||||
"/build/app.75450933.css": "sha384-tW+c6BIV2/3j9k6AiygV7qMVFqcsYG8dNY2CzCcIuMjOf3inAYoZlGvrV8VgfR3+",
|
||||
"/build/app.dd395c1d.js": "sha384-OMqLzNbXe5Dx/gPoy67bNHWTehw5vz1UYQ0GsfRwpaokM+MwBRymwkC4MT28rkjz",
|
||||
"/build/app.6bd98062.css": "sha384-BB9TrvQK/jlfhbdISEdBH5ZaZvbrPnZ+AcocXs1uQNh2yIQFboslUY9Up3iqnY4h",
|
||||
"/build/app-rtl.97153087.js": "sha384-jX7jRUAa8rH29Eg8jLIUKGfGcOT6RBz/P90plXmZPadf2CXKUBdcNGrspaejCHkr",
|
||||
"/build/app-rtl.dbee41f9.css": "sha384-52I26CWbi5SCx6Qr+s+xsgmaVX2Ybd4TVVVoEMP87laXeLbu6jtJ/4ZGwo7YDUQ0",
|
||||
"/build/app-rtl.6a14a027.css": "sha384-ZmUAtO0Z/WfjYxJNleP3rBJpDmAnbaTlFMtejV/YpsshxkqmyNUneCIos9uMX7kH",
|
||||
"/build/export-pdf.1442bee7.js": "sha384-C6agvjJnQUsMCxaZ/J7dUZU3cpC7uHKI9ZtnfGpRqYbjyw91YLy1oV3iNYgwYyCF",
|
||||
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
|
||||
"/build/invoice.7ef8a0c8.js": "sha384-z4lZ1Ig3+NPigrRyGPZoff0gG3n5PnCjaDJ73ATnzdUYk0lOCEtNV9fg35VRD0vG",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"build/app.css": "/build/app.75450933.css",
|
||||
"build/app.js": "/build/app.3463e2a4.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.dbee41f9.css",
|
||||
"build/app.css": "/build/app.6bd98062.css",
|
||||
"build/app.js": "/build/app.dd395c1d.js",
|
||||
"build/app-rtl.css": "/build/app-rtl.6a14a027.css",
|
||||
"build/app-rtl.js": "/build/app-rtl.97153087.js",
|
||||
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
|
||||
"build/export-pdf.js": "/build/export-pdf.1442bee7.js",
|
||||
|
||||
@@ -4,9 +4,6 @@ use App\Kernel;
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||
|
||||
// TODO remove once PARTIAL usage was replaced entirely
|
||||
\Doctrine\Deprecations\Deprecation::ignoreDeprecations('https://github.com/doctrine/orm/issues/8471');
|
||||
|
||||
return function (array $context) {
|
||||
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ use App\Entity\Timesheet;
|
||||
use App\Event\PageActionsEvent;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use Nelmio\ApiDocBundle\Attribute\Model;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\API;
|
||||
|
||||
use App\Activity\ActivityService;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\ActivityRate;
|
||||
use App\Entity\User;
|
||||
@@ -46,7 +47,8 @@ final class ActivityController extends BaseApiController
|
||||
private readonly ViewHandlerInterface $viewHandler,
|
||||
private readonly ActivityRepository $repository,
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly ActivityRateRepository $activityRateRepository
|
||||
private readonly ActivityRateRepository $activityRateRepository,
|
||||
private readonly ActivityService $activityService
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -208,6 +210,25 @@ final class ActivityController extends BaseApiController
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing activity
|
||||
*
|
||||
* [DANGER] This will also delete ALL linked timesheets.
|
||||
* Maybe use `PATCH` instead and mark it as inactive with `visible=false`?
|
||||
*/
|
||||
#[IsGranted('delete', 'activity')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one activity')])]
|
||||
#[OA\Parameter(name: 'id', description: 'Activity ID to delete', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}', name: 'delete_activity', requirements: ['id' => '\d+'], methods: ['DELETE'])]
|
||||
public function deleteAction(Activity $activity): Response
|
||||
{
|
||||
$this->activityService->deleteActivity($activity);
|
||||
|
||||
$view = new View(null, Response::HTTP_NO_CONTENT);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of a meta-field for an existing activity
|
||||
*/
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\API\Model\TimesheetConfig;
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use Nelmio\ApiDocBundle\Attribute\Model;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
@@ -46,7 +46,8 @@ final class CustomerController extends BaseApiController
|
||||
private readonly ViewHandlerInterface $viewHandler,
|
||||
private readonly CustomerRepository $repository,
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly CustomerRateRepository $customerRateRepository
|
||||
private readonly CustomerRateRepository $customerRateRepository,
|
||||
private readonly CustomerService $customerService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -185,6 +186,25 @@ final class CustomerController extends BaseApiController
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing customer
|
||||
*
|
||||
* [DANGER] This will also delete ALL linked projects, project activities and timesheets.
|
||||
* Maybe use `PATCH` instead and mark it as inactive with `visible=false`?
|
||||
*/
|
||||
#[IsGranted('delete', 'customer')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one customer')])]
|
||||
#[OA\Parameter(name: 'id', description: 'Customer ID to delete', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}', name: 'delete_customer', requirements: ['id' => '\d+'], methods: ['DELETE'])]
|
||||
public function deleteAction(Customer $customer): Response
|
||||
{
|
||||
$this->customerService->deleteCustomer($customer);
|
||||
|
||||
$view = new View(null, Response::HTTP_NO_CONTENT);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of a meta-field for an existing customer
|
||||
*/
|
||||
|
||||
@@ -240,6 +240,25 @@ final class ProjectController extends BaseApiController
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing project
|
||||
*
|
||||
* [DANGER] This will also delete ALL linked activities and timesheets.
|
||||
* Maybe use `PATCH` instead and mark it as inactive with `visible=false`?
|
||||
*/
|
||||
#[IsGranted('delete', 'project')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete one project')])]
|
||||
#[OA\Parameter(name: 'id', description: 'Project ID to delete', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}', name: 'delete_project', requirements: ['id' => '\d+'], methods: ['DELETE'])]
|
||||
public function deleteAction(Project $project): Response
|
||||
{
|
||||
$this->projectService->deleteProject($project);
|
||||
|
||||
$view = new View(null, Response::HTTP_NO_CONTENT);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of a meta-field for an existing project
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,7 @@ use App\API\Model\Version;
|
||||
use App\Plugin\PluginManager;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use Nelmio\ApiDocBundle\Annotation\Model;
|
||||
use Nelmio\ApiDocBundle\Attribute\Model;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
@@ -37,6 +37,7 @@ use Symfony\Component\ExpressionLanguage\Expression;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Validator\Constraints;
|
||||
@@ -181,8 +182,11 @@ final class TimesheetController extends BaseApiController
|
||||
/** @var array<string> $tags */
|
||||
$tags = $paramFetcher->get('tags');
|
||||
if (\is_array($tags) && \count($tags) > 0) {
|
||||
$tags = $this->tagRepository->findTagsByName($tags, true);
|
||||
foreach ($tags as $tag) {
|
||||
$tagsByName = $this->tagRepository->findTagsByName($tags, true);
|
||||
if (\count($tagsByName) === 0) {
|
||||
throw new BadRequestHttpException('Given tags were not found');
|
||||
}
|
||||
foreach ($tagsByName as $tag) {
|
||||
$query->addTag($tag);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Entity\Project;
|
||||
use App\Event\ActivityCreateEvent;
|
||||
use App\Event\ActivityCreatePostEvent;
|
||||
use App\Event\ActivityCreatePreEvent;
|
||||
use App\Event\ActivityDeleteEvent;
|
||||
use App\Event\ActivityMetaDefinitionEvent;
|
||||
use App\Event\ActivityUpdatePostEvent;
|
||||
use App\Event\ActivityUpdatePreEvent;
|
||||
@@ -69,8 +70,13 @@ class ActivityService
|
||||
return $activity;
|
||||
}
|
||||
|
||||
public function deleteActivity(Activity $activity): void
|
||||
{
|
||||
$this->dispatcher->dispatch(new ActivityDeleteEvent($activity));
|
||||
$this->repository->deleteActivity($activity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Activity $activity
|
||||
* @param string[] $groups
|
||||
* @throws ValidationFailedException
|
||||
*/
|
||||
|
||||
@@ -26,7 +26,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
*/
|
||||
abstract class AbstractResetCommand extends Command
|
||||
{
|
||||
public function __construct(private string $kernelEnvironment)
|
||||
public function __construct(private readonly string $kernelEnvironment)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
@@ -11,14 +11,13 @@ namespace App\Command;
|
||||
|
||||
use App\Constants;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
@@ -26,10 +25,10 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:install')]
|
||||
#[AsCommand(name: 'kimai:install', description: 'Kimai installation command', aliases: ['kimai:update'])]
|
||||
final class InstallCommand extends Command
|
||||
{
|
||||
public function __construct(private Connection $connection, private string $kernelEnvironment)
|
||||
public function __construct(private readonly Connection $connection)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -37,8 +36,7 @@ final class InstallCommand extends Command
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setDescription('Basic installation for Kimai')
|
||||
->setHelp('This command will perform the basic installation steps to get Kimai up and running.')
|
||||
->setHelp('This command will perform the installation steps to bootstrap the application, database and plugins.')
|
||||
->addOption('no-cache', null, InputOption::VALUE_NONE, 'Skip cache re-generation')
|
||||
;
|
||||
}
|
||||
@@ -47,19 +45,23 @@ final class InstallCommand extends Command
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Kimai installation running ...');
|
||||
$io->text('Start installation ...');
|
||||
|
||||
/** @var Application $application */
|
||||
$application = $this->getApplication();
|
||||
$environment = $application->getKernel()->getEnvironment();
|
||||
|
||||
// create the database, in case it is not yet existing
|
||||
try {
|
||||
$this->createDatabase($io, $input, $output);
|
||||
// creates the database if it is not yet existing
|
||||
$this->createDatabase($io, $output);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to create database: ' . $ex->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// bootstrap database ONLY via doctrine migrations, so all installation will have the correct and same state
|
||||
try {
|
||||
// bootstrap database ONLY via doctrine migrations, so all installation will have the same state
|
||||
$this->importMigrations($io, $output);
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to set migration status: ' . $ex->getMessage());
|
||||
@@ -68,12 +70,22 @@ final class InstallCommand extends Command
|
||||
}
|
||||
|
||||
if (!$input->getOption('no-cache')) {
|
||||
// flush the cache, just to make sure ... and ignore result
|
||||
$this->rebuildCaches($this->kernelEnvironment, $io, $input, $output);
|
||||
// show manual steps in case this fails
|
||||
$cacheResult = $this->rebuildCaches($environment, $io, $input, $output);
|
||||
|
||||
if ($cacheResult !== Command::SUCCESS) {
|
||||
$io->warning(
|
||||
[
|
||||
'Please run the cache commands manually:',
|
||||
'bin/console cache:clear --env=' . $environment . PHP_EOL .
|
||||
'bin/console cache:warmup --env=' . $environment
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$io->success(
|
||||
\sprintf('Congratulations! Successfully installed %s version %s', Constants::SOFTWARE, Constants::VERSION)
|
||||
\sprintf('Successfully installed %s version %s 🎉', Constants::SOFTWARE, Constants::VERSION)
|
||||
);
|
||||
|
||||
return Command::SUCCESS;
|
||||
@@ -81,11 +93,13 @@ final class InstallCommand extends Command
|
||||
|
||||
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io->text('Rebuilding your cache, please be patient ...');
|
||||
$io->text('Rebuilding cache ...');
|
||||
|
||||
$command = $this->getApplication()->find('cache:clear');
|
||||
try {
|
||||
$command->run(new ArrayInput(['--env' => $environment]), $output);
|
||||
if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) {
|
||||
throw new \RuntimeException('Invalid file permissions?');
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to clear cache: ' . $ex->getMessage());
|
||||
|
||||
@@ -94,7 +108,9 @@ final class InstallCommand extends Command
|
||||
|
||||
$command = $this->getApplication()->find('cache:warmup');
|
||||
try {
|
||||
$command->run(new ArrayInput(['--env' => $environment]), $output);
|
||||
if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) {
|
||||
throw new \RuntimeException('Invalid file permissions?');
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error('Failed to warmup cache: ' . $ex->getMessage());
|
||||
|
||||
@@ -106,55 +122,36 @@ final class InstallCommand extends Command
|
||||
|
||||
private function importMigrations(SymfonyStyle $io, OutputInterface $output): void
|
||||
{
|
||||
$io->text('Creating database ...');
|
||||
|
||||
$command = $this->getApplication()->find('doctrine:migrations:migrate');
|
||||
$cmdInput = new ArrayInput(['--allow-no-migration' => true]);
|
||||
$cmdInput->setInteractive(false);
|
||||
$command->run($cmdInput, $output);
|
||||
$result = $command->run($cmdInput, $output);
|
||||
|
||||
$io->writeln('');
|
||||
if (0 !== $result) {
|
||||
throw new \Exception('Failed updating database.');
|
||||
}
|
||||
}
|
||||
|
||||
private function createDatabase(SymfonyStyle $io, InputInterface $input, OutputInterface $output): void
|
||||
private function createDatabase(SymfonyStyle $io, OutputInterface $output): void
|
||||
{
|
||||
try {
|
||||
if ($this->connection->isConnected()) {
|
||||
$io->note(\sprintf('Database is existing and connection could be established'));
|
||||
|
||||
// database exists: we can skip this step
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->askConfirmation($input, $output, \sprintf('Create the database "%s" (yes) or skip (no)?', $this->connection->getDatabase()), true)) {
|
||||
throw new \Exception('Skipped database creation, aborting installation');
|
||||
}
|
||||
} catch (\Exception $exception) {
|
||||
// this likely means that the database does not exist. the latest doctrine release
|
||||
// changed the behavior: in previous version this code did not throw an exception.
|
||||
} catch (\Exception $ex) {
|
||||
// this means the database does not exist and the connection failed
|
||||
}
|
||||
|
||||
$options = ['--if-not-exists' => true];
|
||||
|
||||
$command = $this->getApplication()->find('doctrine:database:create');
|
||||
$result = $command->run(new ArrayInput($options), $output);
|
||||
$cmdInput = new ArrayInput(['--if-not-exists' => true]);
|
||||
$cmdInput->setInteractive(false);
|
||||
$result = $command->run($cmdInput, $output);
|
||||
|
||||
if (0 !== $result) {
|
||||
throw new \Exception('Failed creating database. Check your credentials in DATABASE_URL');
|
||||
throw new \Exception('Failed creating database: check your DATABASE_URL.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
* @param string $question
|
||||
* @param bool $default
|
||||
* @return bool
|
||||
*/
|
||||
private function askConfirmation(InputInterface $input, OutputInterface $output, $question, $default = false): bool
|
||||
{
|
||||
/** @var QuestionHelper $questionHelper */
|
||||
$questionHelper = $this->getHelperSet()->get('question');
|
||||
$text = \sprintf('<info>%s (yes/no)</info> [<comment>%s</comment>]:', $question, $default ? 'yes' : 'no');
|
||||
$question = new ConfirmationQuestion(' ' . $text . ' ', $default, '/^y|yes/i');
|
||||
|
||||
return $questionHelper->ask($input, $output, $question);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,37 +9,144 @@
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Plugin\Package;
|
||||
use App\Plugin\PackageManager;
|
||||
use App\Plugin\Plugin;
|
||||
use App\Plugin\PluginManager;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Process\PhpSubprocess;
|
||||
|
||||
/**
|
||||
* Command used to fetch plugin information.
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:plugins')]
|
||||
#[AsCommand(name: 'kimai:plugins', description: 'Manage Kimai plugins')]
|
||||
final class PluginCommand extends Command
|
||||
{
|
||||
public function __construct(private PluginManager $plugins)
|
||||
public function __construct(
|
||||
private readonly PluginManager $pluginManager,
|
||||
private readonly PackageManager $packageManager
|
||||
)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setDescription('Receive plugin information')
|
||||
->setHelp('This command prints detailed plugin information.')
|
||||
;
|
||||
$this->setHelp('Shows information about already installed plugins by default.');
|
||||
$this->addOption('available', null, InputOption::VALUE_NONE, 'Show list of available plugins in ' . PackageManager::PACKAGE_DIR);
|
||||
$this->addOption('composer', null, InputOption::VALUE_NONE, 'Dump list of available composer packages in ' . PackageManager::PACKAGE_DIR);
|
||||
$this->addOption('install', null, InputOption::VALUE_NONE, 'Run plugins installer, previously installed via ./kimai.sh');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$plugins = $this->plugins->getPlugins();
|
||||
if ($input->getOption('available')) {
|
||||
return $this->listPackages($io, $this->packageManager->getAvailablePackages());
|
||||
} elseif ($input->getOption('composer')) {
|
||||
return $this->listComposerPackages($io, $this->packageManager->getAvailablePackages());
|
||||
} elseif ($input->getOption('install')) {
|
||||
return $this->installPlugins($io, $output, $this->pluginManager->getPlugins());
|
||||
}
|
||||
|
||||
return $this->listInstalledPlugins($io, $this->pluginManager->getPlugins());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Plugin[] $plugins
|
||||
*/
|
||||
private function installPlugins(SymfonyStyle $io, OutputInterface $output, array $plugins): int
|
||||
{
|
||||
foreach ($plugins as $plugin) {
|
||||
$config = $plugin->getPath() . '/migrations/doctrine_migrations.yaml';
|
||||
if (!file_exists($config)) {
|
||||
$config = $plugin->getPath() . '/Migrations/doctrine_migrations.yaml';
|
||||
if (!file_exists($config)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// using getApplication()->find('doctrine:migrations:migrate') does NOT work here
|
||||
// because the Doctrine command can only be executed once
|
||||
// if run more than once it fails with a "Container is frozen" exception
|
||||
|
||||
$process = new PhpSubprocess([
|
||||
'bin/console',
|
||||
'doctrine:migrations:migrate',
|
||||
'--allow-no-migration',
|
||||
'--no-interaction',
|
||||
'--configuration=' . $config
|
||||
]);
|
||||
$process->run();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
$io->error('Failed to install bundle database: ' . PHP_EOL . $config);
|
||||
$io->error($process->getErrorOutput());
|
||||
} else {
|
||||
if ($io->isVerbose()) {
|
||||
$io->write($process->getOutput());
|
||||
} else {
|
||||
$io->success('Successfully installed: ' . $plugin->getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Package[] $packages
|
||||
*/
|
||||
private function listComposerPackages(SymfonyStyle $io, array $packages): int
|
||||
{
|
||||
if (empty($packages)) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$all = [];
|
||||
foreach ($packages as $package) {
|
||||
$all[] = $package->getMetadata()->getPackage();
|
||||
}
|
||||
$io->write(implode(' ', $all));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Package[] $packages
|
||||
*/
|
||||
private function listPackages(SymfonyStyle $io, array $packages): int
|
||||
{
|
||||
if (empty($packages)) {
|
||||
$io->warning('No packages to install found');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($packages as $package) {
|
||||
$metadata = $package->getMetadata();
|
||||
$rows[] = [
|
||||
$metadata->getName(),
|
||||
$metadata->getVersion(),
|
||||
$metadata->getKimaiVersion(),
|
||||
$metadata->getPackage(),
|
||||
$package->getPackageFile()->getPathname(),
|
||||
];
|
||||
}
|
||||
$io->table(['Name', 'Version', 'Requires', 'Package', 'Directory'], $rows);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<Plugin> $plugins
|
||||
*/
|
||||
private function listInstalledPlugins(SymfonyStyle $io, array $plugins): int
|
||||
{
|
||||
if (empty($plugins)) {
|
||||
$io->warning('No plugins installed');
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ use Symfony\Component\Intl\Locales;
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:reset:locales')]
|
||||
#[AsCommand(name: 'kimai:reset:locales', description: 'Regenerate the locale definition file')]
|
||||
final class RegenerateLocalesCommand extends Command
|
||||
{
|
||||
/**
|
||||
@@ -66,11 +66,6 @@ final class RegenerateLocalesCommand extends Command
|
||||
return $this->kernelEnvironment !== 'prod';
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Regenerate the locale definition file');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
@@ -136,7 +131,15 @@ final class RegenerateLocalesCommand extends Command
|
||||
$shortTime = new \IntlDateFormatter($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::SHORT);
|
||||
|
||||
$settings['date'] = $shortDate->getPattern();
|
||||
if ($settings['date'] === false) {
|
||||
$io->error('Invalid date pattern for locale: ' . $locale);
|
||||
continue;
|
||||
}
|
||||
$settings['time'] = $shortTime->getPattern();
|
||||
if ($settings['time'] === false) {
|
||||
$io->error('Invalid time pattern for locale: ' . $locale);
|
||||
continue;
|
||||
}
|
||||
|
||||
// see https://github.com/kimai/kimai/issues/4402 - Korean time format failed parsing
|
||||
// special case when time pattern starts with A / a => this will lead to an error
|
||||
|
||||
@@ -25,12 +25,13 @@ use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Command used to execute all the basic application bootstrapping AFTER "composer install" was executed.
|
||||
*
|
||||
* This command is NOT used during runtime and only meant for developers and the CI processes for quality management.
|
||||
* This is one of the cases where I don't feel like it is necessary to add tests, so lets "cheat" with:
|
||||
* This is one of the cases where it is necessary to add tests:
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:reset:test', description: 'Resets the "test" environment')]
|
||||
@@ -38,6 +39,7 @@ final class ResetTestCommand extends AbstractResetCommand
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
string $kernelEnvironment
|
||||
)
|
||||
{
|
||||
@@ -80,227 +82,127 @@ final class ResetTestCommand extends AbstractResetCommand
|
||||
$project->setCustomer($customer);
|
||||
$this->entityManager->persist($project);
|
||||
|
||||
$user1 = new User();
|
||||
$user1->setPreferenceValue(UserPreference::HOURLY_RATE, 53);
|
||||
$user1->setAlias('Clara Haynes');
|
||||
$user1->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
|
||||
$user1->setTitle('CFO');
|
||||
$user1->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y');
|
||||
$user1->setEnabled(true);
|
||||
$user1->setRoles(['ROLE_CUSTOMER']);
|
||||
$user1->setUserIdentifier('clara_customer');
|
||||
$user1->setEmail('clara_customer@example.com');
|
||||
$token1 = new AccessToken($user1, UserFixtures::DEFAULT_API_TOKEN . '_customer');
|
||||
$token1->setName('Test fixture');
|
||||
|
||||
$user2 = new User();
|
||||
$user2->setPreferenceValue(UserPreference::HOURLY_RATE, 82);
|
||||
$user2->setAlias('John Doe');
|
||||
$user2->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
|
||||
$user2->setTitle('Developer');
|
||||
$user2->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y');
|
||||
$user2->setEnabled(true);
|
||||
$user2->setRoles(['ROLE_USER']);
|
||||
$user2->setUserIdentifier('john_user');
|
||||
$user2->setEmail('john_user@example.com');
|
||||
$token2 = new AccessToken($user2, UserFixtures::DEFAULT_API_TOKEN . '_user');
|
||||
$token2->setName('Test fixture');
|
||||
|
||||
$user3 = new User();
|
||||
$user3->setPreferenceValue(UserPreference::HOURLY_RATE, 35);
|
||||
$user3->setAlias('Chris Deactive');
|
||||
$user3->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
|
||||
$user3->setTitle('Developer (left company)');
|
||||
$user3->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y');
|
||||
$user3->setEnabled(false);
|
||||
$user3->setRoles(['ROLE_USER']);
|
||||
$user3->setUserIdentifier('chris_user');
|
||||
$user3->setEmail('chris_user@example.com');
|
||||
$token3 = new AccessToken($user3, UserFixtures::DEFAULT_API_TOKEN . '_inactive');
|
||||
$token3->setName('Test fixture');
|
||||
|
||||
$user4 = new User();
|
||||
$user4->setPreferenceValue(UserPreference::HOURLY_RATE, 35);
|
||||
$user4->setAlias('Tony Maier');
|
||||
$user4->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
|
||||
$user4->setTitle('Head of Development');
|
||||
$user4->setAvatar('https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg');
|
||||
$user4->setEnabled(true);
|
||||
$user4->setRoles(['ROLE_TEAMLEAD']);
|
||||
$user4->setUserIdentifier('tony_teamlead');
|
||||
$user4->setEmail('tony_teamlead@example.com');
|
||||
$token4 = new AccessToken($user4, UserFixtures::DEFAULT_API_TOKEN . '_teamlead');
|
||||
$token4->setName('Test fixture');
|
||||
|
||||
$user5 = new User();
|
||||
$user5->setPreferenceValue(UserPreference::HOURLY_RATE, 81);
|
||||
$user5->setAlias('Anna Smith');
|
||||
$user5->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
|
||||
$user5->setTitle('Administrator');
|
||||
$user5->setEnabled(true);
|
||||
$user5->setRoles(['ROLE_ADMIN']);
|
||||
$user5->setUserIdentifier('anna_admin');
|
||||
$user5->setEmail('anna_admin@example.com');
|
||||
$token5 = new AccessToken($user5, UserFixtures::DEFAULT_API_TOKEN . '_admin');
|
||||
$token5->setName('Test fixture');
|
||||
|
||||
$user6 = new User();
|
||||
$user6->setPreferenceValue(UserPreference::HOURLY_RATE, 46);
|
||||
$user6->setRegisteredAt(new \DateTime('2018-02-06 23:28:57'));
|
||||
$user6->setTitle('Super Administrator');
|
||||
$user6->setAvatar('/bundles/avanzuadmintheme/img/avatar.png');
|
||||
$user6->setEnabled(true);
|
||||
$user6->setRoles(['ROLE_SUPER_ADMIN']);
|
||||
$user6->setUserIdentifier('susan_super');
|
||||
$user6->setEmail('susan_super@example.com');
|
||||
$token6 = new AccessToken($user6, UserFixtures::DEFAULT_API_TOKEN . '_super');
|
||||
$token6->setName('Test fixture');
|
||||
|
||||
$user7 = new User();
|
||||
$user7->setAlias('Test User 1');
|
||||
$user7->setTitle('Quality Tester 1');
|
||||
$user7->setEnabled(true);
|
||||
$user7->setRoles(['ROLE_USER']);
|
||||
$user7->setUserIdentifier('test_user_1');
|
||||
$user7->setEmail('test_user_1@example.com');
|
||||
$token7 = new AccessToken($user7, UserFixtures::DEFAULT_API_TOKEN . '_qa1');
|
||||
$token7->setName('Test fixture');
|
||||
|
||||
$user8 = new User();
|
||||
$user8->setAlias('Test User 2');
|
||||
$user8->setTitle('Quality Tester 2');
|
||||
$user8->setEnabled(true);
|
||||
$user8->setRoles(['ROLE_USER']);
|
||||
$user8->setUserIdentifier('test_user_2');
|
||||
$user8->setEmail('test_user_2@example.com');
|
||||
$token8 = new AccessToken($user8, UserFixtures::DEFAULT_API_TOKEN . '_qa2');
|
||||
$token8->setName('Test fixture');
|
||||
|
||||
/** @var array<int, array{0: User, 1: AccessToken}> $users */
|
||||
$users = [
|
||||
// 0=id, 1=hourly rate, 2=Alias, 3=registration date, 4=title, 5=avatar, 6=enabled, 7=password, 8=roles, 9=username, 10=username canonical, 11=email, 12=email canonical, 13=salt, 14=last login, 15=confirmation token, 16=password requested at, 17=api_token
|
||||
[
|
||||
1,
|
||||
53,
|
||||
'Clara Haynes',
|
||||
'2018-02-06 23:28:57',
|
||||
'CFO',
|
||||
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y',
|
||||
1,
|
||||
'$2y$04$kKBYJ8sKCOhhakCjm9sCp.TQdwLTS1FPkPiWn2KBmaCA7xFL0NA42',
|
||||
['ROLE_CUSTOMER'],
|
||||
'clara_customer',
|
||||
'clara_customer',
|
||||
'clara_customer@example.com',
|
||||
'clara_customer@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
|
||||
UserFixtures::DEFAULT_API_TOKEN . '_customer',
|
||||
],
|
||||
[
|
||||
2,
|
||||
82,
|
||||
'John Doe',
|
||||
'2018-02-06 23:28:57',
|
||||
'Developer',
|
||||
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y',
|
||||
1,
|
||||
'$2y$04$36P/xyhP6FbnfFYbXy7V0.ioSe8HjMlJQFYnlIzz2T6Agfi8ob6jK',
|
||||
[],
|
||||
'john_user',
|
||||
'john_user',
|
||||
'john_user@example.com',
|
||||
'john_user@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
|
||||
UserFixtures::DEFAULT_API_TOKEN . '_user',
|
||||
],
|
||||
[
|
||||
3,
|
||||
35,
|
||||
'Chris Deactive',
|
||||
'2018-02-06 23:28:57',
|
||||
'Developer (left company)',
|
||||
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y',
|
||||
0,
|
||||
'$2y$04$MLtQBZ9JLzWu1Y01QnNjsuoLm8qC9XRkpUywf6DIbpd9OAL1mEcCi',
|
||||
[],
|
||||
'chris_user',
|
||||
'chris_user',
|
||||
'chris_user@example.com',
|
||||
'chris_user@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
|
||||
UserFixtures::DEFAULT_API_TOKEN . '_inactive',
|
||||
],
|
||||
[
|
||||
4,
|
||||
35,
|
||||
'Tony Maier',
|
||||
'2018-02-06 23:28:57',
|
||||
'Head of Development',
|
||||
'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg',
|
||||
1,
|
||||
'$2y$04$rqxiiExfUVzIYRVL2x4JJumQWNPIG6PazXwrSJm/VQFEesR08Uj5i',
|
||||
['ROLE_TEAMLEAD'],
|
||||
'tony_teamlead',
|
||||
'tony_teamlead',
|
||||
'tony_teamlead@example.com',
|
||||
'tony_teamlead@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
|
||||
UserFixtures::DEFAULT_API_TOKEN . '_teamlead',
|
||||
],
|
||||
[
|
||||
5,
|
||||
81,
|
||||
'Anna Smith',
|
||||
'2018-02-06 23:28:57',
|
||||
'Administrator',
|
||||
null,
|
||||
1,
|
||||
'$2y$04$ct/rVb.naDzYZECnvfTJ2uns/zPHv8.8KcunhTjYFwWQeg1dywI8G',
|
||||
['ROLE_ADMIN'],
|
||||
'anna_admin',
|
||||
'anna_admin',
|
||||
'anna_admin@example.com',
|
||||
'anna_admin@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
|
||||
UserFixtures::DEFAULT_API_TOKEN . '_admin',
|
||||
],
|
||||
[
|
||||
6,
|
||||
46,
|
||||
null,
|
||||
'2018-02-06 23:28:57',
|
||||
'Super Administrator',
|
||||
'/bundles/avanzuadmintheme/img/avatar.png',
|
||||
1,
|
||||
'$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2',
|
||||
['ROLE_SUPER_ADMIN'],
|
||||
'susan_super',
|
||||
'susan_super',
|
||||
'susan_super@example.com',
|
||||
'susan_super@example.com',
|
||||
null,
|
||||
'2020-04-14 09:50:38',
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
|
||||
UserFixtures::DEFAULT_API_TOKEN . '_super',
|
||||
],
|
||||
[
|
||||
7,
|
||||
null,
|
||||
'Test User 1',
|
||||
null,
|
||||
'Quality Tester 1',
|
||||
null,
|
||||
1,
|
||||
'$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2',
|
||||
[],
|
||||
'test_user_1',
|
||||
'test_user_1',
|
||||
'test_user_1@example.com',
|
||||
'test_user_1@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
|
||||
UserFixtures::DEFAULT_API_TOKEN . '_qa1',
|
||||
],
|
||||
[
|
||||
8,
|
||||
null,
|
||||
'Test User 2',
|
||||
null,
|
||||
'Quality Tester 2',
|
||||
null,
|
||||
1,
|
||||
'$2y$04$kuhEEPw/CBMYc3x7SOv27eC1hQSmrtFvgJI2ULRuJeddAVDyrPKJ2',
|
||||
[],
|
||||
'test_user_2',
|
||||
'test_user_2',
|
||||
'test_user_2@example.com',
|
||||
'test_user_2@example.com',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'$2y$13$X8/msijlFUgvRaiGLCJP/ep2hRyjpd.TSNz3cuutZLp05FpuBsYfO',
|
||||
UserFixtures::DEFAULT_API_TOKEN . '_qa2',
|
||||
],
|
||||
[$user1, $token1],
|
||||
[$user2, $token2],
|
||||
[$user3, $token3],
|
||||
[$user4, $token4],
|
||||
[$user5, $token5],
|
||||
[$user6, $token6],
|
||||
[$user7, $token7],
|
||||
[$user8, $token8],
|
||||
];
|
||||
|
||||
$userEntities = [];
|
||||
foreach ($users as $userConf) {
|
||||
$user = new User();
|
||||
foreach ($users as $items) {
|
||||
$user = $items[0];
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, UserFixtures::DEFAULT_PASSWORD));
|
||||
$user->setApiToken($this->passwordHasher->hashPassword($user, UserFixtures::DEFAULT_API_TOKEN));
|
||||
foreach (User::WIZARDS as $wizard) {
|
||||
$user->setWizardAsSeen($wizard);
|
||||
}
|
||||
if ($userConf[1] !== null) {
|
||||
$user->setPreferenceValue(UserPreference::HOURLY_RATE, $userConf[1]);
|
||||
}
|
||||
if ($userConf[2] !== null) {
|
||||
$user->setAlias($userConf[2]);
|
||||
}
|
||||
if ($userConf[3] !== null) {
|
||||
$user->setRegisteredAt(new \DateTime($userConf[3]));
|
||||
}
|
||||
if ($userConf[4] !== null) {
|
||||
$user->setTitle($userConf[4]);
|
||||
}
|
||||
if ($userConf[5] !== null) {
|
||||
$user->setAvatar($userConf[5]);
|
||||
}
|
||||
if ($userConf[6] !== null) {
|
||||
$user->setEnabled((bool) $userConf[6]);
|
||||
}
|
||||
$user->setPassword($userConf[7]);
|
||||
if ($userConf[8] !== null && !empty($userConf[8])) {
|
||||
$user->setRoles($userConf[8]);
|
||||
} else {
|
||||
$user->setRoles(['ROLE_USER']);
|
||||
}
|
||||
$user->setUserIdentifier($userConf[9]);
|
||||
if ($userConf[10] !== null) {
|
||||
// removed field: UsernameCanonical
|
||||
}
|
||||
if ($userConf[11] !== null) {
|
||||
$user->setEmail($userConf[11]);
|
||||
}
|
||||
if ($userConf[12] !== null) {
|
||||
// removed field: EmailCanonical
|
||||
}
|
||||
if ($userConf[17] !== null) {
|
||||
$user->setApiToken($userConf[17]);
|
||||
}
|
||||
$this->entityManager->persist($user);
|
||||
|
||||
$accessToken = new AccessToken($user, $userConf[18]);
|
||||
$accessToken->setName('Test fixture');
|
||||
$accessToken = $items[1];
|
||||
$this->entityManager->persist($accessToken);
|
||||
|
||||
$this->entityManager->persist($user);
|
||||
$userEntities[] = $user;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,9 @@ final class TranslationCommand extends Command
|
||||
->addOption('fill-empty', null, InputOption::VALUE_NONE, 'Pre-fills empty translations with the english version')
|
||||
->addOption('delete-empty', null, InputOption::VALUE_NONE, 'Delete all empty keys and files which have no translated key at all')
|
||||
->addOption('move-resname', null, InputOption::VALUE_REQUIRED, 'Move a resname from one file to another (needs "source" and "target" options)')
|
||||
->addOption('move-all', null, InputOption::VALUE_NONE, 'Move all keys from one file to another (needs "source" and "target" options)')
|
||||
->addOption('source', null, InputOption::VALUE_REQUIRED, 'Single source file to use')
|
||||
->addOption('only-core', null, InputOption::VALUE_NONE, 'Do not include plugin and theme directories')
|
||||
->addOption('target', null, InputOption::VALUE_REQUIRED, 'Single target file to use')
|
||||
// DEEPL TRANSLATION FEATURE - UNTESTED
|
||||
->addOption('translate-locale', null, InputOption::VALUE_REQUIRED, 'Translate into the given locale with Deepl')
|
||||
@@ -68,10 +70,13 @@ final class TranslationCommand extends Command
|
||||
|
||||
$bases = [
|
||||
'core' => $this->projectDirectory . '/translations/*.xlf',
|
||||
'plugins' => $this->projectDirectory . Kernel::PLUGIN_DIRECTORY . '/*/Resources/translations/*.xlf',
|
||||
'theme' => $this->projectDirectory . '/vendor/kevinpapst/tabler-bundle/translations/*.xlf',
|
||||
];
|
||||
|
||||
if (!$input->getOption('only-core')) {
|
||||
$bases['plugins'] = $this->projectDirectory . Kernel::PLUGIN_DIRECTORY . '/*/Resources/translations/*.xlf';
|
||||
$bases['theme'] = $this->projectDirectory . '/vendor/kevinpapst/tabler-bundle/translations/*.xlf';
|
||||
}
|
||||
|
||||
$sources = [];
|
||||
if ($input->getOption('source') !== null) {
|
||||
/** @var string $tmp */
|
||||
@@ -146,6 +151,19 @@ final class TranslationCommand extends Command
|
||||
return $this->moveResname($io, $moveResname, $sources, $targets);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Move all keys from source to target
|
||||
// ==========================================================================
|
||||
if ($input->getOption('move-all')) {
|
||||
if (\count($sources) === 0 || \count($targets) === 0) {
|
||||
$io->error('Moving all keys only works with one source and one target file');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return $this->moveAll($io, $sources, $targets);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Fill empty translations with english version
|
||||
// ==========================================================================
|
||||
@@ -634,4 +652,66 @@ final class TranslationCommand extends Command
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $sources
|
||||
* @param array<string> $targets
|
||||
*/
|
||||
private function moveAll(SymfonyStyle $io, array $sources, array $targets): int
|
||||
{
|
||||
foreach ($sources as $source) {
|
||||
$tmp = basename($source);
|
||||
$pos = strpos($tmp, '.');
|
||||
if ($pos === false) {
|
||||
$io->error('Unexpected filename: ' . $source);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$suffix = substr($tmp, $pos);
|
||||
$target = null;
|
||||
foreach ($targets as $t) {
|
||||
if (str_ends_with($t, $suffix)) {
|
||||
$target = $t;
|
||||
}
|
||||
}
|
||||
|
||||
if ($target === null) {
|
||||
$io->error('Cannot find translation target file for source: ' . $source);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$sourceDocument = new \DOMDocument('1.0');
|
||||
$sourceDocument->load($source);
|
||||
|
||||
$targetDocument = new \DOMDocument('1.0');
|
||||
$targetDocument->load($target);
|
||||
$removeNodes = [];
|
||||
|
||||
/** @var \DOMElement $element */
|
||||
foreach ($sourceDocument->getElementsByTagName('trans-unit') as $element) {
|
||||
$newNode = $targetDocument->importNode($element, true);
|
||||
$targetDocument->documentElement->firstElementChild->firstElementChild->appendChild($newNode); // @phpstan-ignore-line
|
||||
$removeNodes[] = $element;
|
||||
}
|
||||
|
||||
foreach ($removeNodes as $node) {
|
||||
$sourceDocument->documentElement->firstElementChild->firstElementChild->removeChild($node); // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
$xmlDocument = new \DOMDocument('1.0');
|
||||
$xmlDocument->preserveWhiteSpace = false;
|
||||
$xmlDocument->formatOutput = true;
|
||||
$xmlDocument->loadXML($sourceDocument->saveXML()); // @phpstan-ignore-line
|
||||
file_put_contents($source, $xmlDocument->saveXML());
|
||||
|
||||
$xmlDocument = new \DOMDocument('1.0');
|
||||
$xmlDocument->preserveWhiteSpace = false;
|
||||
$xmlDocument->formatOutput = true;
|
||||
$xmlDocument->loadXML($targetDocument->saveXML()); // @phpstan-ignore-line
|
||||
file_put_contents($target, $xmlDocument->saveXML());
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,137 +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\Constants;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Exception\ConnectionException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Command used to update a Kimai installation.
|
||||
*/
|
||||
#[AsCommand(name: 'kimai:update')]
|
||||
final class UpdateCommand extends Command
|
||||
{
|
||||
public function __construct(private Connection $connection, private string $kernelEnvironment)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setDescription('Update your Kimai installation')
|
||||
->setHelp('This command will execute all required steps to update your Kimai installation.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Kimai updates running ...');
|
||||
|
||||
$environment = $this->kernelEnvironment;
|
||||
|
||||
// make sure database is available, Kimai running and installed
|
||||
try {
|
||||
if (!$this->connection->createSchemaManager()->tablesExist(['kimai2_users', 'kimai2_timesheet'])) {
|
||||
$io->error('Tables missing. Did you run the installer already?');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$this->connection->createSchemaManager()->tablesExist(['migration_versions'])) {
|
||||
$io->error('Unknown migration status, aborting database update');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
} catch (ConnectionException $e) {
|
||||
$io->error(['Database connection could not be established.', $e->getMessage()]);
|
||||
|
||||
return Command::FAILURE;
|
||||
} catch (\Exception $ex) {
|
||||
$io->error(['Failed to validate database.', $ex->getMessage()]);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// execute latest doctrine migrations
|
||||
try {
|
||||
$command = $this->getApplication()->find('doctrine:migrations:migrate');
|
||||
$cmdInput = new ArrayInput(['--allow-no-migration' => true]);
|
||||
$cmdInput->setInteractive(false);
|
||||
if (0 !== $command->run($cmdInput, $output)) {
|
||||
throw new \RuntimeException('CRITICAL: problem when migrating database');
|
||||
}
|
||||
|
||||
$io->writeln('');
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// flush the cache, in case values from the database are cached
|
||||
$cacheResult = $this->rebuildCaches($environment, $io, $input, $output);
|
||||
|
||||
if ($cacheResult !== Command::SUCCESS) {
|
||||
$io->warning(
|
||||
[
|
||||
\sprintf('Updated %s to version %s but the cache could not be rebuilt.', Constants::SOFTWARE, Constants::VERSION),
|
||||
'Please run the cache commands manually:',
|
||||
'bin/console cache:clear --env=' . $environment . PHP_EOL .
|
||||
'bin/console cache:warmup --env=' . $environment
|
||||
]
|
||||
);
|
||||
} else {
|
||||
$io->success(
|
||||
\sprintf('Congratulations! Successfully updated %s to version %s', Constants::SOFTWARE, Constants::VERSION)
|
||||
);
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function rebuildCaches(string $environment, SymfonyStyle $io, InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io->text('Rebuilding your cache, please be patient ...');
|
||||
|
||||
$command = $this->getApplication()->find('cache:clear');
|
||||
try {
|
||||
if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) {
|
||||
throw new \RuntimeException('Could not clear cache, missing permissions?');
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$command = $this->getApplication()->find('cache:warmup');
|
||||
try {
|
||||
if (0 !== $command->run(new ArrayInput(['--env' => $environment]), $output)) {
|
||||
throw new \RuntimeException('Could not warmup cache, missing permissions?');
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$io->error($ex->getMessage());
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ final class UserLoginLinkCommand extends Command
|
||||
parent::__construct();
|
||||
$this->addArgument('email', InputArgument::REQUIRED, 'The email of the user');
|
||||
$this->addOption('password-reset', null, InputOption::VALUE_NONE, 'Whether the user needs to reset the password afterwards');
|
||||
$this->addOption('all-auth', null, InputOption::VALUE_NONE, 'Ignore that the user is using an external authentication system');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
@@ -63,7 +64,7 @@ final class UserLoginLinkCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$user->isInternalUser()) {
|
||||
if (!$user->isInternalUser() && !$input->getOption('all-auth')) {
|
||||
$io->error('User does not use internal login');
|
||||
|
||||
return Command::FAILURE;
|
||||
|
||||
@@ -33,10 +33,7 @@ final class SystemConfiguration
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an array item to a given value using "dot" notation.
|
||||
* If no key is given to the method, the entire array will be replaced.
|
||||
*
|
||||
* @internal
|
||||
* Set a new or replace an existing system configuration.
|
||||
*/
|
||||
public function set(string $key, mixed $value): void
|
||||
{
|
||||
@@ -456,6 +453,11 @@ final class SystemConfiguration
|
||||
return $this->getIncrement('quick_entry.recent_activities', 5, 0);
|
||||
}
|
||||
|
||||
public function isBreakTimeEnabled(): bool
|
||||
{
|
||||
return (bool) $this->find('timesheet.rules.break_time_active');
|
||||
}
|
||||
|
||||
// ========== Company configurations ==========
|
||||
|
||||
public function getFinancialYearStart(): ?string
|
||||
|
||||
@@ -17,11 +17,11 @@ class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.23.0';
|
||||
public const VERSION = '2.26.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 22300;
|
||||
public const VERSION_ID = 22600;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -12,18 +12,24 @@ namespace App\Controller\Auth;
|
||||
use App\Configuration\SamlConfigurationInterface;
|
||||
use App\Saml\SamlAuthFactory;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Http\SecurityRequestAttributes;
|
||||
use Symfony\Component\Security\Http\Util\TargetPathTrait;
|
||||
|
||||
#[Route(path: '/saml')]
|
||||
final class SamlController extends AbstractController
|
||||
{
|
||||
use TargetPathTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly SamlAuthFactory $authFactory,
|
||||
private readonly SamlConfigurationInterface $samlConfiguration
|
||||
private readonly SamlConfigurationInterface $samlConfiguration,
|
||||
private readonly Security $security,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -54,8 +60,12 @@ final class SamlController extends AbstractController
|
||||
throw new \RuntimeException($error);
|
||||
}
|
||||
|
||||
// this does set headers and exit as $stay is not set to true
|
||||
$redirectTarget = $session->get('_security.main.target_path');
|
||||
$firewallName = $this->security->getFirewallConfig($request)?->getName();
|
||||
if ($firewallName === null || $firewallName === '') {
|
||||
throw new ServiceUnavailableHttpException(message: 'Unknown firewall.');
|
||||
}
|
||||
|
||||
$redirectTarget = $this->getTargetPath($session, $firewallName);
|
||||
if ($redirectTarget === null || $redirectTarget === '') {
|
||||
$redirectTarget = $this->generateUrl('homepage', [], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
}
|
||||
|
||||
@@ -253,7 +253,6 @@ final class DoctorController extends AbstractController
|
||||
'max_execution_time',
|
||||
'date.timezone',
|
||||
'allow_url_fopen',
|
||||
'allow_url_include',
|
||||
'default_charset',
|
||||
'default_mimetype',
|
||||
'display_errors',
|
||||
|
||||
@@ -85,6 +85,10 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
$table->addColumn('endtime', ['class' => 'd-none d-sm-table-cell text-center text-nowrap', 'orderBy' => 'end']);
|
||||
}
|
||||
|
||||
if ($this->configuration->isBreakTimeEnabled()) {
|
||||
$table->addColumn('break', ['class' => 'text-end text-nowrap']);
|
||||
}
|
||||
|
||||
$table->addColumn('duration', ['class' => 'text-end text-nowrap']);
|
||||
|
||||
if ($canSeeRate) {
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Form\Model\MultiUserTimesheet;
|
||||
use App\Form\TimesheetAdminEditForm;
|
||||
use App\Form\TimesheetMultiUserEditForm;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Repository\Query\TimesheetQueryHint;
|
||||
use App\Utils\PageSetup;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
@@ -111,6 +112,7 @@ final class TimesheetTeamController extends TimesheetAbstractController
|
||||
$tags[] = $tag;
|
||||
}
|
||||
|
||||
$newTimesheets = [];
|
||||
foreach ($allUsers as $user) {
|
||||
$newTimesheet = $entry->createCopy();
|
||||
$newTimesheet->setUser($user);
|
||||
@@ -118,6 +120,11 @@ final class TimesheetTeamController extends TimesheetAbstractController
|
||||
$newTimesheet->addTag($tag);
|
||||
}
|
||||
$this->service->prepareNewTimesheet($newTimesheet, $request);
|
||||
$this->service->validateTimesheet($newTimesheet);
|
||||
$newTimesheets[] = $newTimesheet;
|
||||
}
|
||||
|
||||
foreach ($newTimesheets as $newTimesheet) {
|
||||
$this->service->saveNewTimesheet($newTimesheet);
|
||||
}
|
||||
|
||||
@@ -174,6 +181,7 @@ final class TimesheetTeamController extends TimesheetAbstractController
|
||||
protected function prepareQuery(TimesheetQuery $query): void
|
||||
{
|
||||
$query->setCurrentUser($this->getUser());
|
||||
$query->addQueryHint(TimesheetQueryHint::USER_PREFERENCES); // e.g. for latest approval
|
||||
}
|
||||
|
||||
protected function getCreateForm(Timesheet $entry): FormInterface
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Entity\Customer;
|
||||
use App\Event\CustomerCreateEvent;
|
||||
use App\Event\CustomerCreatePostEvent;
|
||||
use App\Event\CustomerCreatePreEvent;
|
||||
use App\Event\CustomerDeleteEvent;
|
||||
use App\Event\CustomerMetaDefinitionEvent;
|
||||
use App\Event\CustomerUpdatePostEvent;
|
||||
use App\Event\CustomerUpdatePreEvent;
|
||||
@@ -73,6 +74,12 @@ final class CustomerService
|
||||
return $customer;
|
||||
}
|
||||
|
||||
public function deleteCustomer(Customer $customer): void
|
||||
{
|
||||
$this->dispatcher->dispatch(new CustomerDeleteEvent($customer));
|
||||
$this->repository->deleteCustomer($customer);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $groups
|
||||
* @throws ValidationFailedException
|
||||
|
||||
@@ -29,8 +29,8 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
*/
|
||||
final class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
{
|
||||
public const DEFAULT_PASSWORD = 'kitten';
|
||||
public const DEFAULT_API_TOKEN = 'api_kitten';
|
||||
public const DEFAULT_PASSWORD = 'password';
|
||||
public const DEFAULT_API_TOKEN = 'token';
|
||||
public const DEFAULT_AVATAR = 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y';
|
||||
|
||||
public const USERNAME_USER = 'john_user';
|
||||
@@ -43,7 +43,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
public const MIN_RATE = 30;
|
||||
public const MAX_RATE = 120;
|
||||
|
||||
public function __construct(private UserPasswordHasherInterface $passwordHasher)
|
||||
public function __construct(private readonly UserPasswordHasherInterface $passwordHasher)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -65,32 +65,8 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
{
|
||||
$allUsers = $this->getUserDefinition();
|
||||
foreach ($allUsers as $userData) {
|
||||
$user = new User();
|
||||
$user->setAlias($userData[0]);
|
||||
$user->setTitle($userData[1]);
|
||||
$user->setUserIdentifier($userData[2]);
|
||||
$user->setEmail($userData[3]);
|
||||
$user->setRoles([$userData[4]]);
|
||||
$user->setAvatar($userData[5]);
|
||||
$user->setEnabled($userData[6]);
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, $userData[8]));
|
||||
$user->setApiToken($this->passwordHasher->hashPassword($user, $userData[9]));
|
||||
$manager->persist($user);
|
||||
|
||||
$prefs = $this->getUserPreferences($user, $userData[7]);
|
||||
$user->setPreferences($prefs);
|
||||
// better to be able to test the wizard in demo installations
|
||||
/*
|
||||
foreach (User::WIZARDS as $wizard) {
|
||||
$user->setWizardAsSeen($wizard);
|
||||
}
|
||||
*/
|
||||
$manager->persist($prefs[0]);
|
||||
$manager->persist($prefs[1]);
|
||||
|
||||
$accessToken = new AccessToken($user, $userData[10]);
|
||||
$accessToken->setName('Test fixture');
|
||||
$manager->persist($accessToken);
|
||||
$manager->persist($userData[0]);
|
||||
$manager->persist($userData[1]);
|
||||
}
|
||||
|
||||
$manager->flush();
|
||||
@@ -100,7 +76,7 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
/**
|
||||
* @param User $user
|
||||
* @param string|null $timezone
|
||||
* @return array
|
||||
* @return array<UserPreference>
|
||||
*/
|
||||
private function getUserPreferences(User $user, string $timezone = null): array
|
||||
{
|
||||
@@ -162,141 +138,158 @@ final class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
$manager->clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Do NOT set wizard as seen here, because they should be visible in the demo installation.
|
||||
*
|
||||
* @return array<int, array{0: User, 1: AccessToken}>
|
||||
*/
|
||||
private function getUserDefinition(): array
|
||||
{
|
||||
// alias = $userData[0]
|
||||
// title = $userData[1]
|
||||
// username = $userData[2]
|
||||
// email = $userData[3]
|
||||
// roles = [$userData[4]]
|
||||
// avatar = $userData[5]
|
||||
// enabled = $userData[6]
|
||||
// timezone = $userData[7]
|
||||
// password = $userData[8]
|
||||
// api old = $userData[9]
|
||||
// api new = $userData[10]
|
||||
$all = [];
|
||||
|
||||
return [
|
||||
[
|
||||
'John Doe',
|
||||
'Developer',
|
||||
self::USERNAME_USER,
|
||||
'john_user@example.com',
|
||||
User::ROLE_USER,
|
||||
self::DEFAULT_AVATAR,
|
||||
true,
|
||||
'America/Vancouver',
|
||||
self::DEFAULT_PASSWORD,
|
||||
self::DEFAULT_API_TOKEN,
|
||||
self::DEFAULT_API_TOKEN . '_john',
|
||||
],
|
||||
[
|
||||
'John Doe',
|
||||
'Developer',
|
||||
'user',
|
||||
'user@example.com',
|
||||
User::ROLE_USER,
|
||||
self::DEFAULT_AVATAR,
|
||||
true,
|
||||
'America/Vancouver',
|
||||
'password',
|
||||
'password',
|
||||
self::DEFAULT_API_TOKEN . '_user',
|
||||
],
|
||||
// inactive user to test login
|
||||
[
|
||||
'Chris Deactive',
|
||||
'Developer (left company)',
|
||||
'chris_user',
|
||||
'chris_user@example.com',
|
||||
User::ROLE_USER,
|
||||
self::DEFAULT_AVATAR,
|
||||
false,
|
||||
'Australia/Sydney',
|
||||
self::DEFAULT_PASSWORD,
|
||||
self::DEFAULT_API_TOKEN,
|
||||
self::DEFAULT_API_TOKEN . '_inactive',
|
||||
],
|
||||
[
|
||||
'Tony Maier',
|
||||
'Head of Sales',
|
||||
self::USERNAME_TEAMLEAD,
|
||||
'tony_teamlead@example.com',
|
||||
User::ROLE_TEAMLEAD,
|
||||
'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg',
|
||||
true,
|
||||
'Asia/Bangkok',
|
||||
self::DEFAULT_PASSWORD,
|
||||
self::DEFAULT_API_TOKEN,
|
||||
self::DEFAULT_API_TOKEN . '_teamlead',
|
||||
],
|
||||
[
|
||||
'Tony Maier',
|
||||
'Head of Sales',
|
||||
'teamlead',
|
||||
'teamlead@example.com',
|
||||
User::ROLE_TEAMLEAD,
|
||||
'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg',
|
||||
true,
|
||||
'Asia/Bangkok',
|
||||
'password',
|
||||
'password',
|
||||
self::DEFAULT_API_TOKEN . '_tony',
|
||||
],
|
||||
// no avatar to test default image macro
|
||||
[
|
||||
'Anna Smith',
|
||||
'Administrator',
|
||||
self::USERNAME_ADMIN,
|
||||
'anna_admin@example.com',
|
||||
User::ROLE_ADMIN,
|
||||
null,
|
||||
true,
|
||||
'Europe/London',
|
||||
self::DEFAULT_PASSWORD,
|
||||
self::DEFAULT_API_TOKEN,
|
||||
self::DEFAULT_API_TOKEN . '_anna',
|
||||
],
|
||||
[
|
||||
'Anna Smith',
|
||||
'Administrator',
|
||||
'administrator',
|
||||
'administrator@example.com',
|
||||
User::ROLE_ADMIN,
|
||||
null,
|
||||
true,
|
||||
'Europe/London',
|
||||
'password',
|
||||
'password',
|
||||
self::DEFAULT_API_TOKEN . '_admin',
|
||||
],
|
||||
// no alias to test twig username macro
|
||||
[
|
||||
null,
|
||||
'Super Administrator',
|
||||
self::USERNAME_SUPER_ADMIN,
|
||||
'susan_super@example.com',
|
||||
User::ROLE_SUPER_ADMIN,
|
||||
'/touch-icon-192x192.png',
|
||||
true,
|
||||
'Europe/Berlin',
|
||||
self::DEFAULT_PASSWORD,
|
||||
self::DEFAULT_API_TOKEN,
|
||||
self::DEFAULT_API_TOKEN . '_susan',
|
||||
],
|
||||
[
|
||||
null,
|
||||
'Super Administrator',
|
||||
'super_admin',
|
||||
'super_admin@example.com',
|
||||
User::ROLE_SUPER_ADMIN,
|
||||
'/touch-icon-192x192.png',
|
||||
true,
|
||||
'Europe/Berlin',
|
||||
'password',
|
||||
'password',
|
||||
self::DEFAULT_API_TOKEN . '_super',
|
||||
],
|
||||
];
|
||||
$user = new User();
|
||||
$user->setAlias('John Doe');
|
||||
$user->setTitle('Developer');
|
||||
$user->setUserIdentifier(self::USERNAME_USER);
|
||||
$user->setEmail('john_user@example.com');
|
||||
$user->setRoles([User::ROLE_USER]);
|
||||
$user->setAvatar(self::DEFAULT_AVATAR);
|
||||
$user->setEnabled(true);
|
||||
$prefs = $this->getUserPreferences($user, 'America/Vancouver');
|
||||
$user->setPreferences($prefs);
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
|
||||
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
|
||||
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_john');
|
||||
$token->setName('User fixture');
|
||||
$all[] = [$user, $token];
|
||||
|
||||
$user = new User();
|
||||
$user->setAlias('John Doe');
|
||||
$user->setTitle('Developer');
|
||||
$user->setUserIdentifier('user');
|
||||
$user->setEmail('user@example.com');
|
||||
$user->setRoles([User::ROLE_USER]);
|
||||
$user->setAvatar(self::DEFAULT_AVATAR);
|
||||
$user->setEnabled(true);
|
||||
$prefs = $this->getUserPreferences($user, 'America/Vancouver');
|
||||
$user->setPreferences($prefs);
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
|
||||
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
|
||||
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_user');
|
||||
$token->setName('User fixture');
|
||||
$all[] = [$user, $token];
|
||||
|
||||
// inactive user to test login
|
||||
$user = new User();
|
||||
$user->setAlias('Chris Deactive');
|
||||
$user->setTitle('Developer (left company)');
|
||||
$user->setUserIdentifier('chris_user');
|
||||
$user->setEmail('chris_user@example.com');
|
||||
$user->setRoles([User::ROLE_USER]);
|
||||
$user->setAvatar(self::DEFAULT_AVATAR);
|
||||
$user->setEnabled(false);
|
||||
$prefs = $this->getUserPreferences($user, 'Australia/Sydney');
|
||||
$user->setPreferences($prefs);
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
|
||||
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
|
||||
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_inactive');
|
||||
$token->setName('User fixture');
|
||||
$all[] = [$user, $token];
|
||||
|
||||
$user = new User();
|
||||
$user->setAlias('Tony Maier');
|
||||
$user->setTitle('Head of Sales');
|
||||
$user->setUserIdentifier(self::USERNAME_TEAMLEAD);
|
||||
$user->setEmail('tony_teamlead@example.com');
|
||||
$user->setRoles([User::ROLE_TEAMLEAD]);
|
||||
$user->setAvatar('https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg');
|
||||
$user->setEnabled(true);
|
||||
$prefs = $this->getUserPreferences($user, 'Asia/Bangkok');
|
||||
$user->setPreferences($prefs);
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
|
||||
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
|
||||
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_teamlead');
|
||||
$token->setName('User fixture');
|
||||
$all[] = [$user, $token];
|
||||
|
||||
$user = new User();
|
||||
$user->setAlias('Tony Maier');
|
||||
$user->setTitle('Head of Sales');
|
||||
$user->setUserIdentifier('teamlead');
|
||||
$user->setEmail('teamlead@example.com');
|
||||
$user->setRoles([User::ROLE_TEAMLEAD]);
|
||||
$user->setAvatar('https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg');
|
||||
$user->setEnabled(true);
|
||||
$prefs = $this->getUserPreferences($user, 'Asia/Bangkok');
|
||||
$user->setPreferences($prefs);
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
|
||||
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
|
||||
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_tony');
|
||||
$token->setName('User fixture');
|
||||
$all[] = [$user, $token];
|
||||
|
||||
// no avatar to test default image macro
|
||||
$user = new User();
|
||||
$user->setAlias('Anna Smith');
|
||||
$user->setTitle('Administrator');
|
||||
$user->setUserIdentifier(self::USERNAME_ADMIN);
|
||||
$user->setEmail('anna_admin@example.com');
|
||||
$user->setRoles([User::ROLE_ADMIN]);
|
||||
$user->setEnabled(true);
|
||||
$prefs = $this->getUserPreferences($user, 'Europe/London');
|
||||
$user->setPreferences($prefs);
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
|
||||
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
|
||||
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_anna');
|
||||
$token->setName('User fixture');
|
||||
$all[] = [$user, $token];
|
||||
|
||||
$user = new User();
|
||||
$user->setAlias('Anna Smith');
|
||||
$user->setTitle('Administrator');
|
||||
$user->setUserIdentifier('administrator');
|
||||
$user->setEmail('administrator@example.com');
|
||||
$user->setRoles([User::ROLE_ADMIN]);
|
||||
$user->setEnabled(true);
|
||||
$prefs = $this->getUserPreferences($user, 'Europe/London');
|
||||
$user->setPreferences($prefs);
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
|
||||
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
|
||||
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_admin');
|
||||
$token->setName('User fixture');
|
||||
$all[] = [$user, $token];
|
||||
|
||||
// no alias to test twig username macro
|
||||
$user = new User();
|
||||
$user->setTitle('Super Administrator');
|
||||
$user->setUserIdentifier(self::USERNAME_SUPER_ADMIN);
|
||||
$user->setEmail('susan_super@example.com');
|
||||
$user->setRoles([User::ROLE_SUPER_ADMIN]);
|
||||
$user->setAvatar('/touch-icon-192x192.png');
|
||||
$user->setEnabled(true);
|
||||
$prefs = $this->getUserPreferences($user, 'Europe/Berlin');
|
||||
$user->setPreferences($prefs);
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
|
||||
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
|
||||
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_susan');
|
||||
$token->setName('User fixture');
|
||||
$all[] = [$user, $token];
|
||||
|
||||
$user = new User();
|
||||
$user->setTitle('Super Administrator');
|
||||
$user->setUserIdentifier('super_admin');
|
||||
$user->setEmail('super_admin@example.com');
|
||||
$user->setRoles([User::ROLE_SUPER_ADMIN]);
|
||||
$user->setAvatar('/touch-icon-192x192.png');
|
||||
$user->setEnabled(true);
|
||||
$prefs = $this->getUserPreferences($user, 'Europe/Berlin');
|
||||
$user->setPreferences($prefs);
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, self::DEFAULT_PASSWORD));
|
||||
$user->setApiToken($this->passwordHasher->hashPassword($user, self::DEFAULT_API_TOKEN));
|
||||
$token = new AccessToken($user, self::DEFAULT_API_TOKEN . '_super');
|
||||
$token->setName('User fixture');
|
||||
$all[] = [$user, $token];
|
||||
|
||||
return $all;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +331,9 @@ final class Configuration implements ConfigurationInterface
|
||||
->booleanNode('require_activity')
|
||||
->defaultTrue()
|
||||
->end()
|
||||
->booleanNode('break_time_active')
|
||||
->defaultFalse()
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
->end()
|
||||
|
||||
@@ -24,7 +24,7 @@ final class UTCDateTimeImmutableType extends DateTimeImmutableType
|
||||
/**
|
||||
* @param T $value
|
||||
* @return (T is null ? null : string)
|
||||
* @template T<\DateTimeImmutable>
|
||||
* @template T
|
||||
* @throws ConversionException
|
||||
*/
|
||||
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
|
||||
|
||||
@@ -25,7 +25,7 @@ final class UTCDateTimeType extends DateTimeType
|
||||
* @param T $value
|
||||
* @param AbstractPlatform $platform
|
||||
* @return (T is null ? null : string)
|
||||
* @template T<\DateTime>
|
||||
* @template T
|
||||
* @throws ConversionException
|
||||
*/
|
||||
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\AccessTokenRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_access_token')]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\AccessTokenRepository')]
|
||||
#[ORM\Entity(repositoryClass: AccessTokenRepository::class)]
|
||||
#[ORM\UniqueConstraint(columns: ['token'])]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity(fields: ['token'])]
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\Entity;
|
||||
use App\Doctrine\Behavior\CreatedAt;
|
||||
use App\Doctrine\Behavior\CreatedTrait;
|
||||
use App\Export\Annotation as Exporter;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -24,7 +25,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Index(columns: ['visible', 'project_id'])]
|
||||
#[ORM\Index(columns: ['visible', 'project_id', 'name'])]
|
||||
#[ORM\Index(columns: ['visible', 'name'])]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\ActivityRepository')]
|
||||
#[ORM\Entity(repositoryClass: ActivityRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Serializer\VirtualProperty('ProjectName', exp: 'object.getProject() === null ? null : object.getProject().getName()', options: [new Serializer\SerializedName('parentTitle'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Activity'])])]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\ActivityRateRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
@@ -16,7 +17,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_activities_rates')]
|
||||
#[ORM\UniqueConstraint(columns: ['user_id', 'activity_id'])]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\ActivityRateRepository')]
|
||||
#[ORM\Entity(repositoryClass: ActivityRateRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity(['user', 'activity'], ignoreNull: false)]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\BookmarkRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_bookmarks')]
|
||||
#[ORM\UniqueConstraint(columns: ['user_id', 'name'])]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\BookmarkRepository')]
|
||||
#[ORM\Entity(repositoryClass: BookmarkRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity(fields: ['user', 'name'])]
|
||||
class Bookmark
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\ConfigurationRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_configuration')]
|
||||
#[ORM\UniqueConstraint(columns: ['name'])]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\ConfigurationRepository')]
|
||||
#[ORM\Entity(repositoryClass: ConfigurationRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('name')]
|
||||
class Configuration
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\Entity;
|
||||
use App\Doctrine\Behavior\CreatedAt;
|
||||
use App\Doctrine\Behavior\CreatedTrait;
|
||||
use App\Export\Annotation as Exporter;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -22,7 +23,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_customers')]
|
||||
#[ORM\Index(columns: ['visible'])]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\CustomerRepository')]
|
||||
#[ORM\Entity(repositoryClass: CustomerRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable'])]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\CustomerRateRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
@@ -16,7 +17,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_customers_rates')]
|
||||
#[ORM\UniqueConstraint(columns: ['user_id', 'customer_id'])]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\CustomerRateRepository')]
|
||||
#[ORM\Entity(repositoryClass: CustomerRateRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity(['user', 'customer'], ignoreNull: false)]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
|
||||
@@ -11,17 +11,19 @@ namespace App\Entity;
|
||||
|
||||
use App\Export\Annotation as Exporter;
|
||||
use App\Invoice\InvoiceModel;
|
||||
use App\Repository\InvoiceRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_invoices')]
|
||||
#[ORM\UniqueConstraint(columns: ['invoice_number'])]
|
||||
#[ORM\UniqueConstraint(columns: ['invoice_filename'])]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\InvoiceRepository')]
|
||||
#[ORM\Entity(repositoryClass: InvoiceRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('invoiceNumber')]
|
||||
#[UniqueEntity('invoiceFilename')]
|
||||
@@ -65,12 +67,14 @@ class Invoice implements EntityWithMetaFields
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[OA\Property(ref: '#/components/schemas/Customer')]
|
||||
private ?Customer $customer = null;
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
#[Serializer\Expose]
|
||||
#[Serializer\Groups(['Default'])]
|
||||
#[OA\Property(ref: '#/components/schemas/User')]
|
||||
private ?User $user = null;
|
||||
#[ORM\Column(name: 'created_at', type: 'datetime', nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\InvoiceTemplateRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_invoice_templates')]
|
||||
#[ORM\UniqueConstraint(columns: ['name'])]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\InvoiceTemplateRepository')]
|
||||
#[ORM\Entity(repositoryClass: InvoiceTemplateRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('name')]
|
||||
class InvoiceTemplate
|
||||
|
||||
@@ -109,16 +109,14 @@ trait MetaTableTypeTrait
|
||||
|
||||
// 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');
|
||||
} else {
|
||||
$value = (string) $value;
|
||||
}
|
||||
}
|
||||
switch ($this->type) {
|
||||
case YesNoType::class:
|
||||
case CheckboxType::class:
|
||||
if ($value === false || $value === '' || !\is_scalar($value)) {
|
||||
$value = 0;
|
||||
} else {
|
||||
$value = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\Entity;
|
||||
use App\Doctrine\Behavior\CreatedAt;
|
||||
use App\Doctrine\Behavior\CreatedTrait;
|
||||
use App\Export\Annotation as Exporter;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -23,7 +24,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Table(name: 'kimai2_projects')]
|
||||
#[ORM\Index(columns: ['customer_id', 'visible', 'name'])]
|
||||
#[ORM\Index(columns: ['customer_id', 'visible', 'id'])]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\ProjectRepository')]
|
||||
#[ORM\Entity(repositoryClass: ProjectRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
#[Serializer\VirtualProperty('CustomerName', exp: 'object.getCustomer() === null ? null : object.getCustomer().getName()', options: [new Serializer\SerializedName('parentTitle'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Project'])])]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\ProjectRateRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
@@ -16,7 +17,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_projects_rates')]
|
||||
#[ORM\UniqueConstraint(columns: ['user_id', 'project_id'])]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\ProjectRateRepository')]
|
||||
#[ORM\Entity(repositoryClass: ProjectRateRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity(['user', 'project'], ignoreNull: false)]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\RoleRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_roles')]
|
||||
#[ORM\UniqueConstraint(name: 'roles_name', columns: ['name'])]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\RoleRepository')]
|
||||
#[ORM\Entity(repositoryClass: RoleRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('name')]
|
||||
class Role
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\RolePermissionRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_roles_permissions')]
|
||||
#[ORM\UniqueConstraint(name: 'role_permission', columns: ['role_id', 'permission'])]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\RolePermissionRepository')]
|
||||
#[ORM\Entity(repositoryClass: RolePermissionRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity(['role', 'permission'])]
|
||||
class RolePermission
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\TagRepository;
|
||||
use App\Utils\Color;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use JMS\Serializer\Annotation as Serializer;
|
||||
@@ -17,7 +18,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_tags')]
|
||||
#[ORM\UniqueConstraint(columns: ['name'])]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\TagRepository')]
|
||||
#[ORM\Entity(repositoryClass: TagRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('name')]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
|
||||
@@ -178,13 +178,13 @@ class Team
|
||||
}
|
||||
|
||||
/**
|
||||
* @return User[]
|
||||
* @return list<User>
|
||||
*/
|
||||
public function getTeamleads(): array
|
||||
{
|
||||
$leads = [];
|
||||
foreach ($this->members as $member) {
|
||||
if ($member->isTeamlead()) {
|
||||
if ($member->isTeamlead() && $member->getUser() !== null) {
|
||||
$leads[] = $member->getUser();
|
||||
}
|
||||
}
|
||||
@@ -273,13 +273,15 @@ class Team
|
||||
/**
|
||||
* Returns all users in the team, both teamlead and normal member.
|
||||
*
|
||||
* @return User[]
|
||||
* @return list<User>
|
||||
*/
|
||||
public function getUsers(): array
|
||||
{
|
||||
$users = [];
|
||||
foreach ($this->members as $member) {
|
||||
$users[] = $member->getUser();
|
||||
if ($member->getUser() !== null) {
|
||||
$users[] = $member->getUser();
|
||||
}
|
||||
}
|
||||
|
||||
return $users;
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace App\Entity;
|
||||
|
||||
use App\Doctrine\Behavior\ModifiedAt;
|
||||
use App\Doctrine\Behavior\ModifiedTrait;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
@@ -37,7 +38,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Index(columns: ['end_time', 'user', 'start_time'], name: 'IDX_TIMESHEET_TICKTAC')]
|
||||
#[ORM\Index(columns: ['user', 'project_id', 'activity_id'], name: 'IDX_TIMESHEET_RECENT_ACTIVITIES')]
|
||||
#[ORM\Index(columns: ['user', 'id', 'duration'], name: 'IDX_TIMESHEET_RESULT_STATS')]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\TimesheetRepository')]
|
||||
#[ORM\Entity(repositoryClass: TimesheetRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[Serializer\ExclusionPolicy('all')]
|
||||
@@ -624,11 +625,9 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function createCopy(?Timesheet $timesheet = null): Timesheet
|
||||
public function createCopy(): Timesheet
|
||||
{
|
||||
if (null === $timesheet) {
|
||||
$timesheet = new Timesheet();
|
||||
}
|
||||
$timesheet = new Timesheet();
|
||||
|
||||
$values = get_object_vars($this);
|
||||
foreach ($values as $k => $v) {
|
||||
@@ -639,7 +638,9 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
|
||||
|
||||
/** @var TimesheetMeta $meta */
|
||||
foreach ($this->meta as $meta) {
|
||||
$timesheet->setMetaField(clone $meta);
|
||||
$tmp = clone $meta;
|
||||
$tmp->setEntity($timesheet);
|
||||
$timesheet->setMetaField($tmp);
|
||||
}
|
||||
|
||||
$timesheet->tags = new ArrayCollection();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Export\Annotation as Exporter;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Utils\StringHelper;
|
||||
use App\Validator\Constraints as Constraints;
|
||||
use App\WorkingTime\Mode\WorkingTimeModeNone;
|
||||
@@ -33,7 +34,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Table(name: 'kimai2_users')]
|
||||
#[ORM\UniqueConstraint(columns: ['username'])]
|
||||
#[ORM\UniqueConstraint(columns: ['email'])]
|
||||
#[ORM\Entity(repositoryClass: 'App\Repository\UserRepository')]
|
||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('username')]
|
||||
#[UniqueEntity('email')]
|
||||
@@ -752,9 +753,6 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
* This method should not be called by plugins and returns true on success or false on a failure.
|
||||
*
|
||||
* @internal immutable property that cannot be set by plugins
|
||||
* @param bool $canSeeAllData
|
||||
* @return bool
|
||||
* @throws Exception
|
||||
*/
|
||||
public function initCanSeeAllData(bool $canSeeAllData): bool
|
||||
{
|
||||
@@ -1276,7 +1274,17 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
|
||||
public function getWorkStartingDay(): ?\DateTimeInterface
|
||||
{
|
||||
$date = $this->getPreferenceValue(UserPreference::WORK_STARTING_DAY);
|
||||
return $this->getPreferenceDate('work_start_day');
|
||||
}
|
||||
|
||||
public function setWorkStartingDay(?\DateTimeInterface $date): void
|
||||
{
|
||||
$this->setPreferenceValue('work_start_day', $date?->format('Y-m-d'));
|
||||
}
|
||||
|
||||
private function getPreferenceDate(string $prefName): ?\DateTimeInterface
|
||||
{
|
||||
$date = $this->getPreferenceValue($prefName);
|
||||
|
||||
if ($date === null) {
|
||||
return null;
|
||||
@@ -1290,9 +1298,14 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return ($date instanceof \DateTimeInterface) ? $date : null;
|
||||
}
|
||||
|
||||
public function setWorkStartingDay(?\DateTimeInterface $date): void
|
||||
public function getLastWorkingDay(): ?\DateTimeInterface
|
||||
{
|
||||
$this->setPreferenceValue(UserPreference::WORK_STARTING_DAY, $date?->format('Y-m-d'));
|
||||
return $this->getPreferenceDate('work_last_day');
|
||||
}
|
||||
|
||||
public function setLastWorkingDay(?\DateTimeInterface $date): void
|
||||
{
|
||||
$this->setPreferenceValue('work_last_day', $date?->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function getPublicHolidayGroup(): null|string
|
||||
|
||||
@@ -47,7 +47,6 @@ class UserPreference
|
||||
public const WORK_HOURS_SATURDAY = WorkingTimeCalculatorDay::WORK_HOURS_SATURDAY;
|
||||
/** @deprecated since 2.22*/
|
||||
public const WORK_HOURS_SUNDAY = WorkingTimeCalculatorDay::WORK_HOURS_SUNDAY;
|
||||
public const WORK_STARTING_DAY = 'work_start_day';
|
||||
public const PUBLIC_HOLIDAY_GROUP = 'public_holiday_group';
|
||||
public const HOLIDAYS_PER_YEAR = 'holidays';
|
||||
public const WORK_CONTRACT_TYPE = 'work_contract_type';
|
||||
@@ -151,11 +150,26 @@ class UserPreference
|
||||
* integer, float, string, boolean or null
|
||||
*
|
||||
* @param mixed $value
|
||||
* @return UserPreference
|
||||
*/
|
||||
public function setValue($value): UserPreference
|
||||
{
|
||||
$this->value = $value;
|
||||
// unchecked checkboxes / false bool would save an empty string in the database
|
||||
// those cannot be searched in the database
|
||||
switch ($this->type) {
|
||||
case YesNoType::class:
|
||||
case CheckboxType::class:
|
||||
if ($value === false || $value === '' || !\is_scalar($value)) {
|
||||
$value = 0;
|
||||
} else {
|
||||
$value = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
$this->value = $value;
|
||||
} elseif (\is_scalar($value)) {
|
||||
$this->value = (string) $value;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
17
src/Event/ActivityDeleteEvent.php
Normal file
17
src/Event/ActivityDeleteEvent.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
/**
|
||||
* Triggered right before a activity will be deleted.
|
||||
*/
|
||||
final class ActivityDeleteEvent extends AbstractActivityEvent
|
||||
{
|
||||
}
|
||||
17
src/Event/CustomerDeleteEvent.php
Normal file
17
src/Event/CustomerDeleteEvent.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
/**
|
||||
* Triggered right before a customer will be deleted.
|
||||
*/
|
||||
final class CustomerDeleteEvent extends AbstractCustomerEvent
|
||||
{
|
||||
}
|
||||
@@ -157,7 +157,7 @@ class PageActionsEvent extends ThemeEvent
|
||||
|
||||
public function addQuickImport(string $url): void
|
||||
{
|
||||
$this->addAction('import', ['url' => $url, 'class' => 'toolbar-action', 'title' => 'import', 'icon' => 'upload', 'translation_domain' => 'actions']);
|
||||
$this->addAction('import', ['url' => $url, 'class' => 'toolbar-action', 'title' => 'import', 'icon' => 'upload']);
|
||||
}
|
||||
|
||||
public function addQuickExport(string $url): void
|
||||
@@ -172,7 +172,7 @@ class PageActionsEvent extends ThemeEvent
|
||||
|
||||
public function addEdit(string $url, bool $modal = true, string $class = ''): void
|
||||
{
|
||||
$this->addAction('edit', ['url' => $url, 'class' => ($modal ? 'modal-ajax-form' . ($class === '' ? '' : ' ' . $class) : $class), 'translation_domain' => 'actions', 'title' => 'edit']);
|
||||
$this->addAction('edit', ['url' => $url, 'class' => ($modal ? 'modal-ajax-form' . ($class === '' ? '' : ' ' . $class) : $class), 'title' => 'edit']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,20 +180,20 @@ class PageActionsEvent extends ThemeEvent
|
||||
*/
|
||||
public function addSettings(string $url): void
|
||||
{
|
||||
$this->addAction('settings', ['url' => $url, 'class' => 'modal-ajax-form', 'title' => 'settings', 'translation_domain' => 'actions', 'accesskey' => 'h']);
|
||||
$this->addAction('settings', ['url' => $url, 'class' => 'modal-ajax-form', 'title' => 'settings', 'accesskey' => 'h']);
|
||||
}
|
||||
|
||||
public function addConfig(string $url): void
|
||||
{
|
||||
$this->addAction('settings', ['url' => $url, 'title' => 'settings', 'translation_domain' => 'actions']);
|
||||
$this->addAction('settings', ['url' => $url, 'title' => 'settings']);
|
||||
}
|
||||
|
||||
public function addDelete(string $url, bool $remoteConfirm = true): void
|
||||
{
|
||||
if ($remoteConfirm) {
|
||||
$this->addAction('trash', ['url' => $url, 'class' => 'modal-ajax-form text-red', 'translation_domain' => 'actions', 'title' => 'trash']);
|
||||
$this->addAction('trash', ['url' => $url, 'class' => 'modal-ajax-form text-red', 'title' => 'trash']);
|
||||
} else {
|
||||
$this->addAction('trash', ['url' => $url, 'class' => 'confirmation-link text-red', 'attr' => ['data-question' => 'confirm.delete'], 'translation_domain' => 'actions', 'title' => 'trash']);
|
||||
$this->addAction('trash', ['url' => $url, 'class' => 'confirmation-link text-red', 'attr' => ['data-question' => 'confirm.delete'], 'title' => 'trash']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
17
src/Event/ProjectDeleteEvent.php
Normal file
17
src/Event/ProjectDeleteEvent.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Event;
|
||||
|
||||
/**
|
||||
* Triggered right before a project will be deleted.
|
||||
*/
|
||||
final class ProjectDeleteEvent extends AbstractProjectEvent
|
||||
{
|
||||
}
|
||||
@@ -21,7 +21,7 @@ abstract class AbstractActionsSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
private ?string $locale = null;
|
||||
|
||||
public function __construct(private AuthorizationCheckerInterface $auth, private UrlGeneratorInterface $urlGenerator)
|
||||
public function __construct(private readonly AuthorizationCheckerInterface $auth, private readonly UrlGeneratorInterface $urlGenerator)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
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', 'url' => $this->path('restart_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link dd-ts-repeat', 'attr' => ['data-payload' => '{"copy": "all"}', 'data-event' => 'kimai.timesheetStart kimai.timesheetUpdate', 'data-method' => 'PATCH', 'data-msg-error' => 'timesheet.start.error', 'data-msg-success' => 'timesheet.start.success']]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('edit', $timesheet)) {
|
||||
@@ -38,7 +38,7 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
|
||||
|
||||
if ($this->isGranted('duplicate', $timesheet)) {
|
||||
$class = $event->isView('edit') ? '' : 'modal-ajax-form';
|
||||
$event->addAction('copy', ['title' => 'copy', 'translation_domain' => 'actions', 'url' => $this->path($routeDuplicate, ['id' => $timesheet->getId()]), 'class' => $class . ' dd-ts-duplicate']);
|
||||
$event->addAction('copy', ['title' => 'copy', 'url' => $this->path($routeDuplicate, ['id' => $timesheet->getId()]), 'class' => $class . ' dd-ts-duplicate']);
|
||||
}
|
||||
|
||||
if ($event->countActions() > 0) {
|
||||
@@ -49,7 +49,6 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
|
||||
$event->addAction('trash', [
|
||||
'url' => $this->path('delete_timesheet', ['id' => $timesheet->getId()]),
|
||||
'class' => 'api-link text-red dd-ts-trash',
|
||||
'translation_domain' => 'actions',
|
||||
'attr' => [
|
||||
'data-event' => 'kimai.timesheetDelete',
|
||||
'data-method' => 'DELETE',
|
||||
|
||||
@@ -31,7 +31,7 @@ final class ActivitySubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if (!$event->isView('activity_details') && $this->isGranted('view', $activity)) {
|
||||
$event->addAction('details', ['title' => 'details', 'translation_domain' => 'actions', 'url' => $this->path('activity_details', ['id' => $activity->getId()])]);
|
||||
$event->addAction('details', ['title' => 'details', 'url' => $this->path('activity_details', ['id' => $activity->getId()])]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('edit', $activity)) {
|
||||
@@ -40,7 +40,7 @@ final class ActivitySubscriber extends AbstractActionsSubscriber
|
||||
|
||||
if ($this->isGranted('permissions', $activity)) {
|
||||
$class = $event->isView('permissions') ? '' : 'modal-ajax-form';
|
||||
$event->addAction('permissions', ['title' => 'permissions', 'translation_domain' => 'actions', 'url' => $this->path('admin_activity_permissions', ['id' => $activity->getId()]), 'class' => $class]);
|
||||
$event->addAction('permissions', ['title' => 'permissions', 'url' => $this->path('admin_activity_permissions', ['id' => $activity->getId()]), 'class' => $class]);
|
||||
}
|
||||
|
||||
if ($event->countActions() > 0) {
|
||||
@@ -53,7 +53,7 @@ final class ActivitySubscriber extends AbstractActionsSubscriber
|
||||
$parameters['customers[]'] = $activity->getProject()->getCustomer()->getId();
|
||||
$parameters['projects[]'] = $activity->getProject()->getId();
|
||||
}
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet', $parameters)]);
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', $parameters)]);
|
||||
}
|
||||
|
||||
if ($event->hasSubmenu('filter')) {
|
||||
@@ -65,7 +65,7 @@ final class ActivitySubscriber extends AbstractActionsSubscriber
|
||||
if (!$activity->isGlobal()) {
|
||||
$parameters['project'] = $activity->getProject()->getId();
|
||||
}
|
||||
$event->addAction('create-timesheet', ['title' => 'create-timesheet', 'translation_domain' => 'actions', 'icon' => 'start', 'url' => $this->path('admin_timesheet_create', $parameters), 'class' => 'modal-ajax-form']);
|
||||
$event->addAction('create-timesheet', ['title' => 'create-timesheet', 'icon' => 'start', 'url' => $this->path('admin_timesheet_create', $parameters), 'class' => 'modal-ajax-form']);
|
||||
}
|
||||
|
||||
if (($event->isIndexView() || $event->isView('project_details')) && $this->isGranted('delete', $activity)) {
|
||||
|
||||
@@ -34,7 +34,7 @@ final class CustomerSubscriber extends AbstractActionsSubscriber
|
||||
$isListingView = $event->isIndexView() || $event->isCustomView();
|
||||
|
||||
if (!$event->isView('customer_details') && $canView) {
|
||||
$event->addAction('details', ['title' => 'details', 'translation_domain' => 'actions', 'url' => $this->path('customer_details', ['id' => $customer->getId()])]);
|
||||
$event->addAction('details', ['title' => 'details', 'url' => $this->path('customer_details', ['id' => $customer->getId()])]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('edit', $customer)) {
|
||||
@@ -43,7 +43,7 @@ final class CustomerSubscriber extends AbstractActionsSubscriber
|
||||
|
||||
if ($this->isGranted('permissions', $customer)) {
|
||||
$class = $event->isView('permissions') ? '' : 'modal-ajax-form';
|
||||
$event->addAction('permissions', ['title' => 'permissions', 'translation_domain' => 'actions', 'url' => $this->path('admin_customer_permissions', ['id' => $customer->getId()]), 'class' => $class]);
|
||||
$event->addAction('permissions', ['title' => 'permissions', 'url' => $this->path('admin_customer_permissions', ['id' => $customer->getId()]), 'class' => $class]);
|
||||
}
|
||||
|
||||
if ($isListingView) {
|
||||
@@ -61,15 +61,15 @@ final class CustomerSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_project') || $this->isGranted('view_teamlead_project') || $this->isGranted('view_team_project')) {
|
||||
$event->addActionToSubmenu('filter', 'project', ['title' => 'project.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_project', ['customers[]' => $customer->getId()])]);
|
||||
$event->addActionToSubmenu('filter', 'project', ['title' => 'project.filter', 'url' => $this->path('admin_project', ['customers[]' => $customer->getId()])]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_activity')) {
|
||||
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activity.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId()])]);
|
||||
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activity.filter', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId()])]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_other_timesheet')) {
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId()])]);
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId()])]);
|
||||
}
|
||||
|
||||
if ($event->hasSubmenu('filter')) {
|
||||
|
||||
@@ -56,7 +56,7 @@ final class InvoiceSubscriber extends AbstractActionsSubscriber
|
||||
$allowDelete = $this->isGranted('delete_invoice');
|
||||
if (!$invoice->isCanceled()) {
|
||||
$id = $allowDelete ? 'invoice.cancel' : 'trash';
|
||||
$event->addAction($id, ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'canceled', 'token' => $payload['token']]), 'title' => 'invoice.cancel', 'translation_domain' => 'actions']);
|
||||
$event->addAction($id, ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'canceled', 'token' => $payload['token']]), 'title' => 'invoice.cancel']);
|
||||
}
|
||||
|
||||
if ($this->isGranted('delete_invoice')) {
|
||||
|
||||
@@ -34,7 +34,7 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
|
||||
$isListingView = $event->isIndexView() || $event->isCustomView();
|
||||
|
||||
if (!$event->isView('project_details') && $this->isGranted('view', $project)) {
|
||||
$event->addAction('details', ['title' => 'details', 'translation_domain' => 'actions', 'url' => $this->path('project_details', ['id' => $project->getId()])]);
|
||||
$event->addAction('details', ['title' => 'details', 'url' => $this->path('project_details', ['id' => $project->getId()])]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('edit', $project)) {
|
||||
@@ -43,7 +43,7 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
|
||||
|
||||
if ($this->isGranted('permissions', $project)) {
|
||||
$class = $event->isView('permissions') ? '' : 'modal-ajax-form';
|
||||
$event->addAction('permissions', ['title' => 'permissions', 'translation_domain' => 'actions', 'url' => $this->path('admin_project_permissions', ['id' => $project->getId()]), 'class' => $class]);
|
||||
$event->addAction('permissions', ['title' => 'permissions', 'url' => $this->path('admin_project_permissions', ['id' => $project->getId()]), 'class' => $class]);
|
||||
}
|
||||
|
||||
if ($event->countActions() > 0) {
|
||||
@@ -51,11 +51,11 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_activity')) {
|
||||
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activity.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
|
||||
$event->addActionToSubmenu('filter', 'activity', ['title' => 'activity.filter', 'url' => $this->path('admin_activity', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_other_timesheet')) {
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', ['customers[]' => $customer->getId(), 'projects[]' => $project->getId()])]);
|
||||
}
|
||||
|
||||
if ($this->isGranted('create_export')) {
|
||||
@@ -79,7 +79,7 @@ final class ProjectSubscriber extends AbstractActionsSubscriber
|
||||
if (\array_key_exists('token', $payload) && $this->isGranted('edit', $project) && $this->isGranted('create_project')) {
|
||||
$event->addAction(
|
||||
'copy',
|
||||
['title' => 'copy', 'translation_domain' => 'actions', 'url' => $this->path('admin_project_duplicate', ['id' => $project->getId(), 'token' => $payload['token']])]
|
||||
['title' => 'copy', 'url' => $this->path('admin_project_duplicate', ['id' => $project->getId(), 'token' => $payload['token']])]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,14 +46,13 @@ final class TagSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_other_timesheet')) {
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet', ['tags' => $name])]);
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['title' => 'timesheet.filter', 'url' => $this->path('admin_timesheet', ['tags' => $name])]);
|
||||
}
|
||||
|
||||
if ($event->isIndexView() && $this->isGranted('delete_tag')) {
|
||||
$event->addAction('trash', [
|
||||
'url' => $this->path('delete_tag', ['id' => $id]),
|
||||
'class' => 'api-link text-red',
|
||||
'translation_domain' => 'actions',
|
||||
'attr' => [
|
||||
'data-event' => 'kimai.tagDelete kimai.tagUpdate',
|
||||
'data-method' => 'DELETE',
|
||||
|
||||
@@ -36,7 +36,7 @@ final class TeamSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if ($this->isGranted('create_team')) {
|
||||
$event->addAction('copy', ['url' => $this->path('team_duplicate', ['id' => $team->getId()]), 'title' => 'copy', 'translation_domain' => 'actions', 'class' => 'modal-ajax-form']);
|
||||
$event->addAction('copy', ['url' => $this->path('team_duplicate', ['id' => $team->getId()]), 'title' => 'copy', 'class' => 'modal-ajax-form']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ final class TeamSubscriber extends AbstractActionsSubscriber
|
||||
$event->addAction('trash', [
|
||||
'url' => $this->path('delete_team', ['id' => $team->getId()]),
|
||||
'class' => 'api-link text-red',
|
||||
'translation_domain' => 'actions',
|
||||
'attr' => [
|
||||
'data-event' => 'kimai.teamDelete kimai.teamUpdate',
|
||||
'data-method' => 'DELETE',
|
||||
|
||||
@@ -22,7 +22,7 @@ final class TimesheetsTeamSubscriber extends AbstractActionsSubscriber
|
||||
{
|
||||
if ($this->isGranted('create_other_timesheet')) {
|
||||
$event->addAction('create', ['title' => 'create', 'url' => $this->path('admin_timesheet_create'), 'class' => 'create-ts modal-ajax-form']);
|
||||
$event->addAction('multi-user', ['title' => 'create-timesheet-multiuser', 'translation_domain' => 'actions', 'url' => $this->path('admin_timesheet_create_multiuser'), 'class' => 'create-ts-mu modal-ajax-form', 'icon' => 'fas fa-user-plus']);
|
||||
$event->addAction('multi-user', ['title' => 'create-timesheet-multiuser', 'url' => $this->path('admin_timesheet_create_multiuser'), 'class' => 'create-ts-mu modal-ajax-form', 'icon' => 'fas fa-user-plus']);
|
||||
}
|
||||
|
||||
if ($this->isGranted('export_other_timesheet')) {
|
||||
|
||||
@@ -31,7 +31,7 @@ final class UserFormsSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if ($this->isGranted('edit', $user)) {
|
||||
$event->addAction('edit', ['url' => $this->path('user_profile_edit', ['username' => $user->getUserIdentifier()]), 'title' => 'profile-stats', 'translation_domain' => 'actions']);
|
||||
$event->addAction('edit', ['url' => $this->path('user_profile_edit', ['username' => $user->getUserIdentifier()]), 'title' => 'profile-stats']);
|
||||
}
|
||||
if ($this->isGranted('preferences', $user)) {
|
||||
$event->addConfig($this->path('user_profile_preferences', ['username' => $user->getUserIdentifier()]));
|
||||
|
||||
@@ -40,7 +40,7 @@ final class UserSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if ($this->isGranted('view', $user)) {
|
||||
$event->addAction('profile-stats', ['icon' => 'avatar', 'url' => $this->path('user_profile', ['username' => $user->getUserIdentifier()]), 'translation_domain' => 'actions', 'title' => 'profile-stats']);
|
||||
$event->addAction('profile-stats', ['icon' => 'avatar', 'url' => $this->path('user_profile', ['username' => $user->getUserIdentifier()]), 'title' => 'profile-stats']);
|
||||
$event->addDivider();
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ final class UserSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if ($user->isEnabled() && $this->isGranted('view_other_timesheet')) {
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['url' => $this->path('admin_timesheet', ['users[]' => $user->getId()]), 'title' => 'timesheet.filter', 'translation_domain' => 'actions']);
|
||||
$event->addActionToSubmenu('filter', 'timesheet', ['url' => $this->path('admin_timesheet', ['users[]' => $user->getId()]), 'title' => 'timesheet.filter']);
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_team')) {
|
||||
|
||||
@@ -28,7 +28,7 @@ final class AnnotationExtractor implements ExtractorInterface
|
||||
|
||||
/**
|
||||
* @param string $value
|
||||
* @return ColumnDefinition[]
|
||||
* @return list<ColumnDefinition>
|
||||
* @throws ExtractorException
|
||||
*/
|
||||
public function extract($value): array
|
||||
@@ -154,11 +154,7 @@ final class AnnotationExtractor implements ExtractorInterface
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($columns as $name => $definition) {
|
||||
if (null === $definition) {
|
||||
unset($columns[$name]);
|
||||
}
|
||||
}
|
||||
$columns = array_filter($columns, function ($value) { return $value !== null; });
|
||||
|
||||
return array_values($columns);
|
||||
}
|
||||
|
||||
@@ -11,11 +11,12 @@ namespace App\Export;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use App\Repository\Query\ExportQuery;
|
||||
use App\Repository\Query\TimesheetQueryHint;
|
||||
use App\Repository\TimesheetRepository;
|
||||
|
||||
final class TimesheetExportRepository implements ExportRepositoryInterface
|
||||
{
|
||||
public function __construct(private TimesheetRepository $repository)
|
||||
public function __construct(private readonly TimesheetRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -41,7 +42,12 @@ final class TimesheetExportRepository implements ExportRepositoryInterface
|
||||
|
||||
public function getExportItemsForQuery(ExportQuery $query): iterable
|
||||
{
|
||||
return $this->repository->getTimesheetsForQuery($query, true);
|
||||
$query->addQueryHint(TimesheetQueryHint::CUSTOMER_META_FIELDS);
|
||||
$query->addQueryHint(TimesheetQueryHint::PROJECT_META_FIELDS);
|
||||
$query->addQueryHint(TimesheetQueryHint::ACTIVITY_META_FIELDS);
|
||||
$query->addQueryHint(TimesheetQueryHint::USER_PREFERENCES);
|
||||
|
||||
return $this->repository->getTimesheetResult($query)->getResults();
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
|
||||
@@ -29,6 +29,7 @@ final class MultiUpdateTable extends AbstractType
|
||||
|
||||
$builder->add('entities', HiddenType::class, [
|
||||
'required' => false,
|
||||
'attr' => ['class' => 'multi_update_ids']
|
||||
]);
|
||||
|
||||
$builder->get('entities')->addModelTransformer(
|
||||
|
||||
@@ -26,7 +26,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
*/
|
||||
final class ActivityType extends AbstractType
|
||||
{
|
||||
public function __construct(private ActivityHelper $activityHelper, private ProjectHelper $projectHelper)
|
||||
public function __construct(private readonly ActivityHelper $activityHelper, private readonly ProjectHelper $projectHelper)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -35,10 +35,12 @@ final class ActivityType extends AbstractType
|
||||
return $this->activityHelper->getChoiceLabel($activity);
|
||||
}
|
||||
|
||||
public function groupBy(Activity $activity, $key, $index): ?string
|
||||
public function groupBy(Activity $activity, $key, $index): string
|
||||
{
|
||||
if (null === $activity->getProject()) {
|
||||
return null;
|
||||
// this creates a optgroup with an empty title. previously this was null, which resulted in options without optgroup
|
||||
// and those are ordered by Tomselect at the top - so globals always came first, see #4674
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->projectHelper->getChoiceLabel($activity->getProject());
|
||||
|
||||
@@ -109,7 +109,7 @@ class Kernel extends BaseKernel
|
||||
throw new \Exception(\sprintf('Bundle "%s" does not implement %s, which is not supported since 2.0.', $bundleName, PluginInterface::class));
|
||||
}
|
||||
|
||||
$meta = new PluginMetadata($fullPath);
|
||||
$meta = PluginMetadata::createFromPath($fullPath);
|
||||
|
||||
if ($meta->getKimaiVersion() > Constants::VERSION_ID) {
|
||||
throw new \Exception(\sprintf('Bundle "%s" requires minimum Kimai version %s, but yours is lower: %s (%s). Please update Kimai or use a lower Plugin version.', $bundleName, $meta->getKimaiVersion(), Constants::VERSION, Constants::VERSION_ID));
|
||||
@@ -169,11 +169,8 @@ class Kernel extends BaseKernel
|
||||
$routes->import($configDir . '/routes/' . $this->environment . '/*.yaml');
|
||||
}
|
||||
|
||||
// load application routes
|
||||
$routes->import($configDir . '/routes.yaml');
|
||||
|
||||
foreach ($this->getBundles() as $bundle) {
|
||||
if (str_contains(\get_class($bundle), 'KimaiPlugin\\')) {
|
||||
if ($bundle instanceof PluginInterface || str_contains(\get_class($bundle), 'KimaiPlugin\\')) {
|
||||
if (is_dir($bundle->getPath() . '/Resources/config/')) {
|
||||
$routes->import($bundle->getPath() . '/Resources/config/routes' . self::CONFIG_EXTS);
|
||||
} elseif (is_dir($bundle->getPath() . '/config/')) {
|
||||
@@ -181,5 +178,8 @@ class Kernel extends BaseKernel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// load application routes as last one, so bundles cannot override application ones
|
||||
$routes->import($configDir . '/routes.yaml');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Configuration\MailConfiguration;
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\Mailer\Envelope;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mime\Address;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\Mime\RawMessage;
|
||||
|
||||
@@ -31,7 +32,11 @@ final class KimaiMailer implements MailerInterface
|
||||
}
|
||||
|
||||
if (\count($message->getFrom()) === 0) {
|
||||
$message->from($this->configuration->getFromAddress());
|
||||
$fallback = $this->configuration->getFromAddress();
|
||||
if ($fallback === null) {
|
||||
throw new \RuntimeException('Missing email "from" address');
|
||||
}
|
||||
$message->from(new Address($fallback, 'Kimai'));
|
||||
}
|
||||
|
||||
$this->mailer->send($message);
|
||||
|
||||
@@ -17,7 +17,7 @@ use DateTimeInterface;
|
||||
final class MonthlyStatistic implements DateStatisticInterface
|
||||
{
|
||||
/**
|
||||
* @var array<string|int, array<int<1, 12>, StatisticDate>>
|
||||
* @var array<string, array<int<1, 12>, StatisticDate>>
|
||||
*/
|
||||
private array $years = [];
|
||||
private DateTimeInterface $begin;
|
||||
@@ -50,7 +50,7 @@ final class MonthlyStatistic implements DateStatisticInterface
|
||||
$day = (int) $begin->format('d');
|
||||
|
||||
while ($tmp < $this->end) {
|
||||
$curYear = $tmp->format('Y');
|
||||
$curYear = (string) $tmp->format('Y');
|
||||
if (!isset($years[$curYear])) {
|
||||
$year = [];
|
||||
for ($i = 1; $i < 13; $i++) {
|
||||
@@ -67,7 +67,7 @@ final class MonthlyStatistic implements DateStatisticInterface
|
||||
}
|
||||
$tmp->modify('+1 month');
|
||||
}
|
||||
$this->years = $years;
|
||||
$this->years = $years; // @phpstan-ignore assign.propertyType
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,17 +14,6 @@ use Symfony\Component\HttpKernel\DependencyInjection\Extension;
|
||||
|
||||
abstract class AbstractPluginExtension extends Extension
|
||||
{
|
||||
protected function registerIcon(ContainerBuilder $container, string $name, string $icon): void
|
||||
{
|
||||
$container->setParameter(
|
||||
'tabler_bundle.icons',
|
||||
array_merge(
|
||||
$container->getParameter('tabler_bundle.icons'),
|
||||
[$name => $icon]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected function registerBundleConfiguration(ContainerBuilder $container, array $configs): void
|
||||
{
|
||||
$bundleConfig = [$this->getAlias() => $configs];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user