Release 2.15 (#4749)
This commit is contained in:
@@ -26,7 +26,7 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
#[Route(path: '/actions')]
|
||||
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
|
||||
#[IsGranted('IS_AUTHENTICATED')]
|
||||
#[OA\Tag(name: 'Actions')]
|
||||
final class ActionsController extends BaseApiController
|
||||
{
|
||||
|
||||
@@ -33,7 +33,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route(path: '/activities')]
|
||||
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
|
||||
#[IsGranted('IS_AUTHENTICATED')]
|
||||
#[OA\Tag(name: 'Activity')]
|
||||
final class ActivityController extends BaseApiController
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
|
||||
#[IsGranted('IS_AUTHENTICATED')]
|
||||
#[OA\Tag(name: 'Default')]
|
||||
final class ConfigurationController extends BaseApiController
|
||||
{
|
||||
|
||||
@@ -33,7 +33,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route(path: '/customers')]
|
||||
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
|
||||
#[IsGranted('IS_AUTHENTICATED')]
|
||||
#[OA\Tag(name: 'Customer')]
|
||||
final class CustomerController extends BaseApiController
|
||||
{
|
||||
|
||||
@@ -35,7 +35,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Validator\Constraints;
|
||||
|
||||
#[Route(path: '/projects')]
|
||||
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
|
||||
#[IsGranted('IS_AUTHENTICATED')]
|
||||
#[OA\Tag(name: 'Project')]
|
||||
final class ProjectController extends BaseApiController
|
||||
{
|
||||
|
||||
@@ -20,7 +20,7 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
|
||||
#[IsGranted('IS_AUTHENTICATED')]
|
||||
#[OA\Tag(name: 'Default')]
|
||||
final class StatusController extends BaseApiController
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route(path: '/tags')]
|
||||
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
|
||||
#[IsGranted('IS_AUTHENTICATED')]
|
||||
#[OA\Tag(name: 'Tag')]
|
||||
final class TagController extends BaseApiController
|
||||
{
|
||||
|
||||
@@ -30,7 +30,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route(path: '/teams')]
|
||||
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
|
||||
#[IsGranted('IS_AUTHENTICATED')]
|
||||
#[OA\Tag(name: 'Team')]
|
||||
final class TeamController extends BaseApiController
|
||||
{
|
||||
|
||||
@@ -42,7 +42,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Validator\Constraints;
|
||||
|
||||
#[Route(path: '/timesheets')]
|
||||
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
|
||||
#[IsGranted('IS_AUTHENTICATED')]
|
||||
#[OA\Tag(name: 'Timesheet')]
|
||||
final class TimesheetController extends BaseApiController
|
||||
{
|
||||
@@ -85,14 +85,14 @@ final class TimesheetController extends BaseApiController
|
||||
#[Rest\QueryParam(name: 'tags', map: true, strict: true, nullable: true, default: [], description: 'List of tag names, e.g. tags[]=bar&tags[]=foo')]
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|begin|end|rate', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, begin, end, rate (default: begin)')]
|
||||
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: DESC)')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5)')]
|
||||
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records before this date will be included (format: HTML5)')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'end', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records before this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'exported', requirements: '0|1', strict: true, nullable: true, description: 'Use this flag if you want to filter for export state. Allowed values: 0=not exported, 1=exported (default: all)')]
|
||||
#[Rest\QueryParam(name: 'active', requirements: '0|1', strict: true, nullable: true, description: 'Filter for running/active records. Allowed values: 0=stopped, 1=active (default: all)')]
|
||||
#[Rest\QueryParam(name: 'billable', requirements: '0|1', strict: true, nullable: true, description: 'Filter for non-/billable records. Allowed values: 0=non-billable, 1=billable (default: all)')]
|
||||
#[Rest\QueryParam(name: 'full', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Allows to fetch full objects including subresources. Allowed values: 0|1|false|true (default: false)')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'modified_after', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records changed after this date will be included (format: HTML5). Available since Kimai 1.10 and works only for records that were created/updated since then.')]
|
||||
#[Rest\QueryParam(name: 'modified_after', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records changed after this date will be included (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository, ProjectRepository $projectRepository, ActivityRepository $activityRepository, UserRepository $userRepository): Response
|
||||
{
|
||||
$query = new TimesheetQuery(false);
|
||||
@@ -398,7 +398,7 @@ final class TimesheetController extends BaseApiController
|
||||
#[IsGranted('view_own_timesheet')]
|
||||
#[OA\Response(response: 200, description: 'Returns the collection of recent user activities (always the latest entry of a unique working set grouped by customer, project and activity)', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/TimesheetCollectionExpanded')))]
|
||||
#[Route(methods: ['GET'], path: '/recent', name: 'recent_timesheet')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included. Default: today - 1 year (format: HTML5)')]
|
||||
#[Rest\QueryParam(name: 'begin', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records after this date will be included. Default: today - 1 year (format: HTML5 datetime-local, e.g. YYYY-MM-DDThh:mm:ss)')]
|
||||
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries (default: 10)')]
|
||||
public function recentAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
|
||||
@@ -33,7 +33,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route(path: '/users')]
|
||||
#[IsGranted('IS_AUTHENTICATED_REMEMBERED')]
|
||||
#[IsGranted('IS_AUTHENTICATED')]
|
||||
#[OA\Tag(name: 'User')]
|
||||
final class UserController extends BaseApiController
|
||||
{
|
||||
|
||||
@@ -127,6 +127,8 @@ final class SystemConfiguration
|
||||
*/
|
||||
public function offsetExists($offset): bool
|
||||
{
|
||||
@trigger_error('The method "SystemConfiguration::offsetExists()" is deprecated, use "has()" instead', E_USER_DEPRECATED);
|
||||
|
||||
return $this->has($offset);
|
||||
}
|
||||
|
||||
@@ -135,6 +137,8 @@ final class SystemConfiguration
|
||||
*/
|
||||
public function offsetGet($offset): mixed
|
||||
{
|
||||
@trigger_error('The method "SystemConfiguration::offsetGet()" is deprecated, use "find()" instead', E_USER_DEPRECATED);
|
||||
|
||||
return $this->find($offset);
|
||||
}
|
||||
|
||||
@@ -143,16 +147,9 @@ final class SystemConfiguration
|
||||
*/
|
||||
public function offsetSet(mixed $offset, mixed $value): void
|
||||
{
|
||||
$this->set($offset, $value);
|
||||
}
|
||||
@trigger_error('The method "SystemConfiguration::offsetSet()" is deprecated, use "set()" instead', E_USER_DEPRECATED);
|
||||
|
||||
/**
|
||||
* @deprecated since 2.0.35
|
||||
* @throws \BadMethodCallException
|
||||
*/
|
||||
public function offsetUnset(mixed $offset): void
|
||||
{
|
||||
throw new \BadMethodCallException('SystemBundleConfiguration does not support offsetUnset()');
|
||||
$this->set($offset, $value);
|
||||
}
|
||||
|
||||
// ========== Authentication configurations ==========
|
||||
|
||||
@@ -17,11 +17,11 @@ class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.14.0';
|
||||
public const VERSION = '2.15.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 21400;
|
||||
public const VERSION_ID = 21500;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -199,7 +199,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
}
|
||||
}
|
||||
|
||||
protected function logException(\Exception $ex): void
|
||||
protected function logException(\Throwable $ex): void
|
||||
{
|
||||
$this->container->get('logger')->critical($ex->getMessage());
|
||||
}
|
||||
|
||||
@@ -265,6 +265,7 @@ final class DoctorController extends AbstractController
|
||||
'sys_temp_dir',
|
||||
'date.timezone',
|
||||
'session.gc_maxlifetime',
|
||||
'disable_functions'
|
||||
];
|
||||
|
||||
$settings = [];
|
||||
|
||||
@@ -61,7 +61,7 @@ final class PluginController extends AbstractController
|
||||
$item->expiresAfter(86400); // one day
|
||||
|
||||
return $response->toArray();
|
||||
} catch (\Exception $exception) {
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logException($exception);
|
||||
$this->flashError('Could not download plugin information');
|
||||
}
|
||||
|
||||
@@ -88,12 +88,12 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
|
||||
if ($canSeeRate) {
|
||||
$table->addColumn('hourlyRate', ['class' => 'text-end d-none text-nowrap']);
|
||||
$table->addColumn('internalRate', ['class' => 'text-end text-nowrap d-none d-md-table-cell']);
|
||||
$table->addColumn('internalRate', ['class' => 'text-end text-nowrap d-none d-xxl-table-cell']);
|
||||
$table->addColumn('rate', ['class' => 'text-end text-nowrap']);
|
||||
}
|
||||
|
||||
$table->addColumn('customer', ['class' => 'd-none d-md-table-cell']);
|
||||
$table->addColumn('project', ['class' => 'd-none d-lg-table-cell']);
|
||||
$table->addColumn('project', ['class' => 'd-none d-xl-table-cell']);
|
||||
$table->addColumn('activity', ['class' => 'd-none d-xl-table-cell']);
|
||||
$table->addColumn('description', ['class' => 'd-none']);
|
||||
$table->addColumn('tags', ['class' => 'd-none badges', 'orderBy' => false]);
|
||||
@@ -103,7 +103,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
}
|
||||
|
||||
if ($canSeeUsername) {
|
||||
$table->addColumn('username', ['class' => 'd-none d-sm-table-cell', 'orderBy' => false]);
|
||||
$table->addColumn('username', ['class' => 'd-none d-md-table-cell', 'orderBy' => false]);
|
||||
}
|
||||
|
||||
$table->addColumn('billable', ['class' => 'text-center d-none w-min', 'orderBy' => false]);
|
||||
|
||||
@@ -11,9 +11,9 @@ namespace App\Doctrine\Extensions;
|
||||
|
||||
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
|
||||
use Doctrine\ORM\Query\AST\Node;
|
||||
use Doctrine\ORM\Query\Lexer;
|
||||
use Doctrine\ORM\Query\Parser;
|
||||
use Doctrine\ORM\Query\SqlWalker;
|
||||
use Doctrine\ORM\Query\TokenType;
|
||||
|
||||
final class Date extends FunctionNode
|
||||
{
|
||||
@@ -26,9 +26,9 @@ final class Date extends FunctionNode
|
||||
|
||||
public function parse(Parser $parser): void
|
||||
{
|
||||
$parser->match(Lexer::T_IDENTIFIER);
|
||||
$parser->match(Lexer::T_OPEN_PARENTHESIS);
|
||||
$parser->match(TokenType::T_IDENTIFIER);
|
||||
$parser->match(TokenType::T_OPEN_PARENTHESIS);
|
||||
$this->value = $parser->ArithmeticPrimary();
|
||||
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
|
||||
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ namespace App\Doctrine\Extensions;
|
||||
|
||||
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
|
||||
use Doctrine\ORM\Query\AST\Node;
|
||||
use Doctrine\ORM\Query\Lexer;
|
||||
use Doctrine\ORM\Query\Parser;
|
||||
use Doctrine\ORM\Query\SqlWalker;
|
||||
use Doctrine\ORM\Query\TokenType;
|
||||
|
||||
final class Day extends FunctionNode
|
||||
{
|
||||
@@ -26,9 +26,9 @@ final class Day extends FunctionNode
|
||||
|
||||
public function parse(Parser $parser): void
|
||||
{
|
||||
$parser->match(Lexer::T_IDENTIFIER);
|
||||
$parser->match(Lexer::T_OPEN_PARENTHESIS);
|
||||
$parser->match(TokenType::T_IDENTIFIER);
|
||||
$parser->match(TokenType::T_OPEN_PARENTHESIS);
|
||||
$this->value = $parser->ArithmeticPrimary();
|
||||
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
|
||||
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ namespace App\Doctrine\Extensions;
|
||||
|
||||
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
|
||||
use Doctrine\ORM\Query\AST\Node;
|
||||
use Doctrine\ORM\Query\Lexer;
|
||||
use Doctrine\ORM\Query\Parser;
|
||||
use Doctrine\ORM\Query\SqlWalker;
|
||||
use Doctrine\ORM\Query\TokenType;
|
||||
|
||||
final class Month extends FunctionNode
|
||||
{
|
||||
@@ -26,9 +26,9 @@ final class Month extends FunctionNode
|
||||
|
||||
public function parse(Parser $parser): void
|
||||
{
|
||||
$parser->match(Lexer::T_IDENTIFIER);
|
||||
$parser->match(Lexer::T_OPEN_PARENTHESIS);
|
||||
$parser->match(TokenType::T_IDENTIFIER);
|
||||
$parser->match(TokenType::T_OPEN_PARENTHESIS);
|
||||
$this->value = $parser->ArithmeticPrimary();
|
||||
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
|
||||
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ namespace App\Doctrine\Extensions;
|
||||
|
||||
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
|
||||
use Doctrine\ORM\Query\AST\Node;
|
||||
use Doctrine\ORM\Query\Lexer;
|
||||
use Doctrine\ORM\Query\Parser;
|
||||
use Doctrine\ORM\Query\SqlWalker;
|
||||
use Doctrine\ORM\Query\TokenType;
|
||||
|
||||
final class Year extends FunctionNode
|
||||
{
|
||||
@@ -26,9 +26,9 @@ final class Year extends FunctionNode
|
||||
|
||||
public function parse(Parser $parser): void
|
||||
{
|
||||
$parser->match(Lexer::T_IDENTIFIER);
|
||||
$parser->match(Lexer::T_OPEN_PARENTHESIS);
|
||||
$parser->match(TokenType::T_IDENTIFIER);
|
||||
$parser->match(TokenType::T_OPEN_PARENTHESIS);
|
||||
$this->value = $parser->ArithmeticPrimary();
|
||||
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
|
||||
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\Doctrine;
|
||||
use Doctrine\DBAL\Platforms\AbstractPlatform;
|
||||
use Doctrine\DBAL\Types\ConversionException;
|
||||
use Doctrine\DBAL\Types\DateTimeType;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
|
||||
final class UTCDateTimeType extends DateTimeType
|
||||
{
|
||||
@@ -67,7 +68,7 @@ final class UTCDateTimeType extends DateTimeType
|
||||
if (!$converted) {
|
||||
throw ConversionException::conversionFailedFormat(
|
||||
$value,
|
||||
$this->getName(),
|
||||
Types::DATETIME_MUTABLE,
|
||||
$platform->getDateTimeFormatString()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ use App\Invoice\InvoiceModel;
|
||||
|
||||
final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
|
||||
{
|
||||
private const DATE_PROCESS_FORMAT = 'Y-m-d h:i:s';
|
||||
|
||||
private InvoiceModel $model;
|
||||
|
||||
public function setInvoiceModel(InvoiceModel $model): void
|
||||
@@ -75,17 +77,24 @@ final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
|
||||
'entry.duration_format' => $formatter->getFormattedDuration($item->getDuration()),
|
||||
'entry.duration_decimal' => $formatter->getFormattedDecimalDuration($item->getDuration()),
|
||||
'entry.duration_minutes' => (int) ($item->getDuration() / 60),
|
||||
'entry.begin' => $formatter->getFormattedDateTime($begin),
|
||||
'entry.begin_time' => $formatter->getFormattedTime($begin),
|
||||
'entry.begin_timestamp' => $begin->getTimestamp(),
|
||||
'entry.end' => $formatter->getFormattedDateTime($end),
|
||||
'entry.end_time' => $formatter->getFormattedTime($end),
|
||||
'entry.end_timestamp' => $end->getTimestamp(),
|
||||
'entry.date' => $formatter->getFormattedDateTime($begin),
|
||||
'entry.week' => \intval($begin->format('W')),
|
||||
'entry.weekyear' => $begin->format('o'),
|
||||
];
|
||||
|
||||
if ($begin !== null) {
|
||||
$values['entry.begin'] = $formatter->getFormattedDateTime($begin);
|
||||
$values['entry.begin_time'] = $formatter->getFormattedTime($begin);
|
||||
$values['entry.begin_timestamp'] = $begin->getTimestamp();
|
||||
$values['entry.date'] = $formatter->getFormattedDateTime($begin);
|
||||
$values['entry.date_process'] = $begin->format(self::DATE_PROCESS_FORMAT); // since 2.14
|
||||
$values['entry.week'] = \intval($begin->format('W'));
|
||||
$values['entry.weekyear'] = $begin->format('o');
|
||||
}
|
||||
|
||||
if ($end !== null) {
|
||||
$values['entry.end'] = $formatter->getFormattedDateTime($end);
|
||||
$values['entry.end_time'] = $formatter->getFormattedTime($end);
|
||||
$values['entry.end_timestamp'] = $end->getTimestamp();
|
||||
}
|
||||
|
||||
if (null !== $user) {
|
||||
$values = array_merge($values, [
|
||||
'entry.user_id' => $user->getId(),
|
||||
|
||||
@@ -66,6 +66,8 @@ final class InvoiceModelActivityHydrator implements InvoiceModelHydrator
|
||||
$prefix . 'id' => $activity->getId(),
|
||||
$prefix . 'name' => $activity->getName() ?? '',
|
||||
$prefix . 'comment' => $activity->getComment() ?? '',
|
||||
$prefix . 'number' => $activity->getNumber() ?? '',
|
||||
$prefix . 'invoice_text' => $activity->getInvoiceText() ?? '',
|
||||
];
|
||||
|
||||
if ($model->getQuery()?->getEnd() !== null) {
|
||||
|
||||
@@ -79,6 +79,8 @@ final class InvoiceModelProjectHydrator implements InvoiceModelHydrator
|
||||
$prefix . 'budget_time' => $project->getTimeBudget(),
|
||||
$prefix . 'budget_time_decimal' => $formatter->getFormattedDecimalDuration($project->getTimeBudget()),
|
||||
$prefix . 'budget_time_minutes' => (int) ($project->getTimeBudget() / 60),
|
||||
$prefix . 'number' => $project->getNumber() ?? '',
|
||||
$prefix . 'invoice_text' => $project->getInvoiceText() ?? '',
|
||||
];
|
||||
|
||||
if ($model->getQuery()?->getEnd() !== null) {
|
||||
|
||||
@@ -17,7 +17,14 @@ use App\Entity\Project;
|
||||
class ActivityQuery extends ProjectQuery
|
||||
{
|
||||
public const ACTIVITY_ORDER_ALLOWED = [
|
||||
'name', 'description' => 'comment', 'customer', 'project', 'budget', 'timeBudget', 'visible'
|
||||
'name',
|
||||
'description' => 'comment',
|
||||
'activity_number' => 'number',
|
||||
'customer',
|
||||
'project',
|
||||
'budget',
|
||||
'timeBudget',
|
||||
'visible'
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,8 +16,17 @@ class ProjectQuery extends BaseQuery implements VisibilityInterface
|
||||
use VisibilityTrait;
|
||||
|
||||
public const PROJECT_ORDER_ALLOWED = [
|
||||
'name', 'description' => 'comment', 'customer', 'orderNumber', 'orderDate',
|
||||
'project_start', 'project_end', 'budget', 'timeBudget', 'visible'
|
||||
'name',
|
||||
'description' => 'comment',
|
||||
'project_number' => 'number',
|
||||
'customer',
|
||||
'orderNumber',
|
||||
'orderDate',
|
||||
'project_start',
|
||||
'project_end',
|
||||
'budget',
|
||||
'timeBudget',
|
||||
'visible'
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,7 +62,11 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
|
||||
|
||||
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
|
||||
{
|
||||
if (!($user instanceof User)) {
|
||||
if (!($user instanceof User) || !$user->isInternalUser()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user->getPassword() === $newHashedPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@ final class SamlAuthenticationSuccessHandler extends DefaultAuthenticationSucces
|
||||
}
|
||||
|
||||
$relayState = $request->get('RelayState');
|
||||
if (null !== $relayState && $relayState !== $this->httpUtils->generateUri($request, $this->options['login_path'])) {
|
||||
$loginUrl = $this->httpUtils->generateUri($request, $this->options['login_path']);
|
||||
|
||||
if ($relayState !== null && $relayState !== '' && $relayState !== $loginUrl) {
|
||||
return $relayState;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,9 +52,9 @@ final class TimesheetStatisticService
|
||||
->addSelect('DAY(t.date) as day')
|
||||
->addSelect('MONTH(t.date) as month')
|
||||
->addSelect('YEAR(t.date) as year')
|
||||
->where($qb->expr()->isNotNull('t.end'))
|
||||
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
|
||||
->andWhere($qb->expr()->in('t.user', ':user'))
|
||||
->andWhere($qb->expr()->isNotNull('t.end'))
|
||||
->setParameter('begin', $begin->format('Y-m-d'))
|
||||
->setParameter('end', $end->format('Y-m-d'))
|
||||
->setParameter('user', $users)
|
||||
@@ -124,9 +124,9 @@ final class TimesheetStatisticService
|
||||
->addSelect('IDENTITY(t.project) as project')
|
||||
->addSelect('IDENTITY(t.activity) as activity')
|
||||
->addSelect('DATE(t.date) as date')
|
||||
->where($qb->expr()->isNotNull('t.end'))
|
||||
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
|
||||
->andWhere($qb->expr()->in('t.user', ':user'))
|
||||
->andWhere($qb->expr()->isNotNull('t.end'))
|
||||
->setParameter('begin', $begin->format('Y-m-d'))
|
||||
->setParameter('end', $end->format('Y-m-d'))
|
||||
->setParameter('user', $users)
|
||||
@@ -200,9 +200,9 @@ final class TimesheetStatisticService
|
||||
->addSelect('IDENTITY(t.activity) as activity')
|
||||
->addSelect('YEAR(t.date) as year')
|
||||
->addSelect('MONTH(t.date) as month')
|
||||
->where($qb->expr()->isNotNull('t.end'))
|
||||
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
|
||||
->andWhere($qb->expr()->in('t.user', ':user'))
|
||||
->andWhere($qb->expr()->isNotNull('t.end'))
|
||||
->setParameter('begin', $begin->format('Y-m-d'))
|
||||
->setParameter('end', $end->format('Y-m-d'))
|
||||
->setParameter('user', $users)
|
||||
@@ -292,9 +292,9 @@ final class TimesheetStatisticService
|
||||
->addSelect('MONTH(t.date) as month')
|
||||
->addSelect('YEAR(t.date) as year')
|
||||
->addSelect('IDENTITY(t.user) as user')
|
||||
->where($qb->expr()->isNotNull('t.end'))
|
||||
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
|
||||
->andWhere($qb->expr()->in('t.user', ':user'))
|
||||
->andWhere($qb->expr()->isNotNull('t.end'))
|
||||
->setParameter('begin', $begin->format('Y-m-d'))
|
||||
->setParameter('end', $end->format('Y-m-d'))
|
||||
->setParameter('user', $users)
|
||||
|
||||
@@ -1,206 +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\Widget\DataProvider;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Model\Statistic\Day;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use DateTime;
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
* This class should really be deleted and replaced by TimesheetStatisticService::getDailyStatistics()
|
||||
* @deprecated since 2.0
|
||||
* @codeCoverageIgnore
|
||||
* @internal
|
||||
* @final
|
||||
*/
|
||||
class DailyWorkingTimeChartProvider
|
||||
{
|
||||
public function __construct(private readonly TimesheetRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* In case this method is called with one timezone and the results are from another timezone,
|
||||
* it might return rows outside the time-range.
|
||||
*
|
||||
* @return array<mixed>
|
||||
*/
|
||||
protected function getDailyData(DateTimeInterface $begin, DateTimeInterface $end, ?User $user = null): array
|
||||
{
|
||||
$qb = $this->repository->createQueryBuilder('t');
|
||||
|
||||
$or = $qb->expr()->orX();
|
||||
$or->add($qb->expr()->between(':begin', 't.begin', 't.end'));
|
||||
$or->add($qb->expr()->between(':end', 't.begin', 't.end'));
|
||||
$or->add($qb->expr()->between('t.begin', ':begin', ':end'));
|
||||
$or->add($qb->expr()->between('t.end', ':begin', ':end'));
|
||||
|
||||
$qb->select('t, p, a, c')
|
||||
->andWhere($qb->expr()->isNotNull('t.end'))
|
||||
->andWhere($or)
|
||||
->orderBy('t.begin', 'DESC')
|
||||
->setParameter('begin', $begin)
|
||||
->setParameter('end', $end)
|
||||
->leftJoin('t.activity', 'a')
|
||||
->leftJoin('t.project', 'p')
|
||||
->leftJoin('p.customer', 'c')
|
||||
;
|
||||
|
||||
if (null !== $user) {
|
||||
$qb
|
||||
->andWhere($qb->expr()->eq('t.user', ':user'))
|
||||
->setParameter('user', $user)
|
||||
;
|
||||
}
|
||||
|
||||
$timesheets = $qb->getQuery()->getResult();
|
||||
|
||||
$results = [];
|
||||
/** @var Timesheet $result */
|
||||
foreach ($timesheets as $result) {
|
||||
/** @var DateTime $beginTmp */
|
||||
$beginTmp = $result->getBegin();
|
||||
/** @var DateTime $endTmp */
|
||||
$endTmp = $result->getEnd();
|
||||
$dateKeyEnd = $endTmp->format('Ymd');
|
||||
|
||||
do {
|
||||
$dateKey = $beginTmp->format('Ymd');
|
||||
|
||||
if ($dateKey !== $dateKeyEnd) {
|
||||
$newDateBegin = clone $beginTmp;
|
||||
$newDateBegin->add(new \DateInterval('P1D'));
|
||||
// overlapping records should always start at midnight
|
||||
$newDateBegin->setTime(0, 0, 0);
|
||||
} else {
|
||||
$newDateBegin = clone $endTmp;
|
||||
}
|
||||
|
||||
// make sure to exclude entries that are outside the requested time-range:
|
||||
// these entries can exist if you have long running entries that started before $begin
|
||||
// for statistical reasons we have to include everything between $begin and $end while
|
||||
// excluding everything that is outside of that range
|
||||
// --------------------------------------------------------------------------------------
|
||||
// Be aware that this will NOT filter every record, in case there is a timezone mismatch between the
|
||||
// begin/end dates and the ones from the database (eg. recorded in UTC) - which might actually be
|
||||
// before $begin (which happens thanks to the timezone conversion when querying the database)
|
||||
if ($newDateBegin > $begin && $beginTmp < $end) {
|
||||
if (!isset($results[$dateKey])) {
|
||||
$results[$dateKey] = [
|
||||
'rate' => 0,
|
||||
'duration' => 0,
|
||||
'billable' => 0, // duration
|
||||
'month' => $beginTmp->format('n'),
|
||||
'year' => $beginTmp->format('Y'),
|
||||
'day' => $beginTmp->format('j'),
|
||||
'details' => []
|
||||
];
|
||||
}
|
||||
$duration = $newDateBegin->getTimestamp() - $beginTmp->getTimestamp();
|
||||
$durationPercent = 0;
|
||||
if ($result->getDuration() !== null && $result->getDuration() > 0) {
|
||||
$durationPercent = $duration / $result->getDuration();
|
||||
}
|
||||
$rate = $result->getRate() * $durationPercent;
|
||||
|
||||
$results[$dateKey]['rate'] += $rate;
|
||||
$results[$dateKey]['duration'] += $duration;
|
||||
if ($result->isBillable()) {
|
||||
$results[$dateKey]['billable'] += $duration;
|
||||
}
|
||||
$detailsId =
|
||||
$result->getProject()->getCustomer()->getId()
|
||||
. '_' . $result->getProject()->getId()
|
||||
. '_' . $result->getActivity()->getId()
|
||||
;
|
||||
|
||||
if (!isset($results[$dateKey]['details'][$detailsId])) {
|
||||
$results[$dateKey]['details'][$detailsId] = [
|
||||
'project' => $result->getProject(),
|
||||
'activity' => $result->getActivity(),
|
||||
'duration' => 0,
|
||||
'rate' => 0,
|
||||
'billable' => 0, // duration
|
||||
];
|
||||
}
|
||||
|
||||
$results[$dateKey]['details'][$detailsId]['duration'] += $duration;
|
||||
$results[$dateKey]['details'][$detailsId]['rate'] += $rate;
|
||||
if ($result->isBillable()) {
|
||||
$results[$dateKey]['details'][$detailsId]['billable'] += $duration;
|
||||
}
|
||||
}
|
||||
|
||||
$beginTmp = $newDateBegin;
|
||||
|
||||
// yes, we only want to compare the day, not the time
|
||||
if ((int) $end->format('Ymd') < (int) $newDateBegin->format('Ymd')) {
|
||||
break;
|
||||
}
|
||||
} while ($dateKey !== $dateKeyEnd);
|
||||
}
|
||||
|
||||
ksort($results);
|
||||
|
||||
foreach ($results as $key => $value) {
|
||||
$results[$key]['details'] = array_values($results[$key]['details']);
|
||||
}
|
||||
|
||||
return array_values($results);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 2.0 - use TimesheetStatisticService::getDailyStatistics() instead
|
||||
*
|
||||
* @param User|null $user
|
||||
* @param DateTimeInterface $begin
|
||||
* @param DateTimeInterface $end
|
||||
* @return Day[]
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getData(?User $user, DateTimeInterface $begin, DateTimeInterface $end): array
|
||||
{
|
||||
/** @var Day[] $days */
|
||||
$days = [];
|
||||
|
||||
// prefill the array
|
||||
$tmp = DateTime::createFromInterface($end);
|
||||
$until = (int) $begin->format('Ymd');
|
||||
while ((int) $tmp->format('Ymd') >= $until) {
|
||||
$last = clone $tmp;
|
||||
$days[$last->format('Ymd')] = new Day($last, 0, 0.00);
|
||||
$tmp->modify('-1 day');
|
||||
}
|
||||
|
||||
$results = $this->getDailyData($begin, $end, $user);
|
||||
|
||||
foreach ($results as $statRow) {
|
||||
$dateTime = DateTime::createFromInterface($begin);
|
||||
$dateTime->setDate($statRow['year'], $statRow['month'], $statRow['day']);
|
||||
$dateTime->setTime(0, 0, 0);
|
||||
$day = new Day($dateTime, (int) $statRow['duration'], (float) $statRow['rate']);
|
||||
$day->setTotalDurationBillable($statRow['billable']);
|
||||
$day->setDetails($statRow['details']);
|
||||
$dateKey = $dateTime->format('Ymd');
|
||||
// make sure entries from other timezones are filtered
|
||||
if (!\array_key_exists($dateKey, $days)) {
|
||||
continue;
|
||||
}
|
||||
$days[$dateKey] = $day;
|
||||
}
|
||||
|
||||
ksort($days);
|
||||
|
||||
return array_values($days);
|
||||
}
|
||||
}
|
||||
@@ -11,17 +11,21 @@ namespace App\Widget\Type;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Model\Statistic\Day;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use App\Widget\DataProvider\DailyWorkingTimeChartProvider;
|
||||
use App\Widget\WidgetInterface;
|
||||
use DateTime;
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
* This is rendered inside the PaginatedWorkingTimeChart.
|
||||
*/
|
||||
final class DailyWorkingTimeChart extends AbstractWidget
|
||||
{
|
||||
public function __construct(private DailyWorkingTimeChartProvider $dailyWorkingTimeChartProvider)
|
||||
public function __construct(private readonly TimesheetRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -47,7 +51,7 @@ final class DailyWorkingTimeChart extends AbstractWidget
|
||||
|
||||
/**
|
||||
* @param array<string, string|bool|int|null|array<string, mixed>> $options
|
||||
@return array<string, string|bool|int|null|array<string, mixed>>
|
||||
* @return array<string, string|bool|int|null|array<string, mixed>>
|
||||
*/
|
||||
public function getOptions(array $options = []): array
|
||||
{
|
||||
@@ -71,7 +75,7 @@ final class DailyWorkingTimeChart extends AbstractWidget
|
||||
$begin = $options['begin'];
|
||||
if (!($begin instanceof \DateTimeInterface)) {
|
||||
if (\is_string($begin)) {
|
||||
$begin = new DateTime($begin, new \DateTimeZone($user->getTimezone()));
|
||||
$begin = new \DateTimeImmutable($begin, new \DateTimeZone($user->getTimezone()));
|
||||
} else {
|
||||
$begin = $dateTimeFactory->getStartOfWeek();
|
||||
}
|
||||
@@ -80,14 +84,14 @@ final class DailyWorkingTimeChart extends AbstractWidget
|
||||
$end = $options['end'];
|
||||
if (!($end instanceof \DateTimeInterface)) {
|
||||
if (\is_string($end)) {
|
||||
$end = new DateTime($end, new \DateTimeZone($user->getTimezone()));
|
||||
$end = new \DateTimeImmutable($end, new \DateTimeZone($user->getTimezone()));
|
||||
} else {
|
||||
$end = $dateTimeFactory->getEndOfWeek($begin);
|
||||
}
|
||||
}
|
||||
|
||||
$activities = [];
|
||||
$statistics = $this->dailyWorkingTimeChartProvider->getData($user, $begin, $end);
|
||||
$statistics = $this->getPreparedData($user, $begin, $end);
|
||||
|
||||
foreach ($statistics as $day) {
|
||||
foreach ($day->getDetails() as $entry) {
|
||||
@@ -125,4 +129,120 @@ final class DailyWorkingTimeChart extends AbstractWidget
|
||||
{
|
||||
return 'widget/widget-dailyworkingtimechart.html.twig';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{'duration': int, 'billable': int, 'month': numeric-string, 'year': numeric-string, 'day': numeric-string, 'details': array<int|string, array<mixed>>}>
|
||||
*/
|
||||
private function getDailyData(DateTimeInterface $begin, DateTimeInterface $end, User $user): array
|
||||
{
|
||||
$qb = $this->repository->createQueryBuilder('t');
|
||||
|
||||
$qb->select('t, p, a, c')
|
||||
->andWhere($qb->expr()->between('t.date', ':begin', ':end'))
|
||||
->andWhere($qb->expr()->eq('t.user', ':user'))
|
||||
->andWhere($qb->expr()->isNotNull('t.end'))
|
||||
->setParameter('begin', $begin->format('Y-m-d'))
|
||||
->setParameter('end', $end->format('Y-m-d'))
|
||||
->setParameter('user', $user)
|
||||
->leftJoin('t.activity', 'a')
|
||||
->leftJoin('t.project', 'p')
|
||||
->leftJoin('p.customer', 'c')
|
||||
;
|
||||
|
||||
$timesheets = $qb->getQuery()->getResult();
|
||||
|
||||
$results = [];
|
||||
/** @var Timesheet $result */
|
||||
foreach ($timesheets as $result) {
|
||||
/** @var DateTime $beginTmp */
|
||||
$beginTmp = $result->getBegin();
|
||||
|
||||
$dateKey = $beginTmp->format('Ymd');
|
||||
|
||||
if (!isset($results[$dateKey])) {
|
||||
$results[$dateKey] = [
|
||||
'duration' => 0,
|
||||
'billable' => 0, // duration
|
||||
'month' => $beginTmp->format('n'),
|
||||
'year' => $beginTmp->format('Y'),
|
||||
'day' => $beginTmp->format('j'),
|
||||
'details' => []
|
||||
];
|
||||
}
|
||||
$duration = $result->getDuration() ?? 0;
|
||||
|
||||
$results[$dateKey]['duration'] += $duration;
|
||||
if ($result->isBillable()) {
|
||||
$results[$dateKey]['billable'] += $duration;
|
||||
}
|
||||
$detailsId =
|
||||
$result->getProject()->getCustomer()->getId()
|
||||
. '_' . ($result->getProject()?->getId() ?? '')
|
||||
. '_' . ($result->getActivity()?->getId() ?? '')
|
||||
;
|
||||
|
||||
if (!isset($results[$dateKey]['details'][$detailsId])) {
|
||||
$results[$dateKey]['details'][$detailsId] = [
|
||||
'project' => $result->getProject(),
|
||||
'activity' => $result->getActivity(),
|
||||
'duration' => 0,
|
||||
'billable' => 0, // duration
|
||||
];
|
||||
}
|
||||
|
||||
$results[$dateKey]['details'][$detailsId]['duration'] += $duration;
|
||||
if ($result->isBillable()) {
|
||||
$results[$dateKey]['details'][$detailsId]['billable'] += $duration;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($results);
|
||||
|
||||
foreach ($results as $key => $value) {
|
||||
$results[$key]['details'] = array_values($value['details']);
|
||||
}
|
||||
|
||||
return array_values($results);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Day[]
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function getPreparedData(User $user, DateTimeInterface $begin, DateTimeInterface $end): array
|
||||
{
|
||||
/** @var Day[] $days */
|
||||
$days = [];
|
||||
|
||||
// prefill the array
|
||||
$tmp = DateTime::createFromInterface($end);
|
||||
$until = (int) $begin->format('Ymd');
|
||||
while ((int) $tmp->format('Ymd') >= $until) {
|
||||
$last = clone $tmp;
|
||||
$days[$last->format('Ymd')] = new Day($last, 0, 0.00);
|
||||
$tmp->modify('-1 day');
|
||||
}
|
||||
|
||||
// TODO replace with TimesheetStatisticService::getDailyStatistics()
|
||||
$results = $this->getDailyData($begin, $end, $user);
|
||||
|
||||
foreach ($results as $statRow) {
|
||||
$dateTime = DateTime::createFromInterface($begin);
|
||||
$dateTime->setDate((int) $statRow['year'], (int) $statRow['month'], (int) $statRow['day']);
|
||||
$dateTime->setTime(0, 0, 0);
|
||||
$day = new Day($dateTime, (int) $statRow['duration'], 0.00); // rate is not used in frontend
|
||||
$day->setTotalDurationBillable($statRow['billable']);
|
||||
$day->setDetails($statRow['details']);
|
||||
$dateKey = $dateTime->format('Ymd');
|
||||
// make sure entries from other timezones are filtered
|
||||
if (!\array_key_exists($dateKey, $days)) {
|
||||
continue;
|
||||
}
|
||||
$days[$dateKey] = $day;
|
||||
}
|
||||
|
||||
ksort($days);
|
||||
|
||||
return array_values($days);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user