Release 2.45 (#5721)

Co-authored-by: Henning Klein <info@henningklein.de>
This commit is contained in:
Kevin Papst
2025-12-21 16:42:34 +01:00
committed by GitHub
parent 8c1ed68817
commit afb5a0bba4
67 changed files with 905 additions and 254 deletions

View File

@@ -86,7 +86,7 @@ final class ActivityController extends BaseApiController
}
$visible = $paramFetcher->get('visible');
if (\is_string($visible) && $visible !== '') {
if (is_numeric($visible)) {
$query->setVisibility((int) $visible);
}

View File

@@ -17,7 +17,6 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
abstract class BaseApiController extends AbstractController
{
@@ -85,8 +84,11 @@ abstract class BaseApiController extends AbstractController
$size = $all['size'];
if (is_numeric($size)) {
$size = (int) $size;
if ($size < 1 || $size > self::MAX_PAGE_SIZE) {
throw new BadRequestHttpException('Size must be between 1 and ' . self::MAX_PAGE_SIZE);
if ($size < 1) {
$size = BaseQuery::DEFAULT_PAGESIZE;
}
if ($size > self::MAX_PAGE_SIZE) {
$size = self::MAX_PAGE_SIZE;
}
$query->setPageSize($size);
}

View File

@@ -76,7 +76,7 @@ final class CustomerController extends BaseApiController
}
$visible = $paramFetcher->get('visible');
if (\is_string($visible) && $visible !== '') {
if (is_numeric($visible)) {
$query->setVisibility((int) $visible);
}

View File

@@ -99,7 +99,7 @@ final class ProjectController extends BaseApiController
}
$visible = $paramFetcher->get('visible');
if (\is_string($visible) && $visible !== '') {
if (is_numeric($visible)) {
$query->setVisibility((int) $visible);
}

View File

@@ -20,6 +20,7 @@ use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\TeamQuery;
use App\Repository\TeamRepository;
use App\User\TeamService;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use OpenApi\Attributes as OA;
@@ -41,7 +42,8 @@ final class TeamController extends BaseApiController
public function __construct(
private readonly ViewHandlerInterface $viewHandler,
private readonly TeamRepository $repository
private readonly TeamRepository $repository,
private readonly TeamService $teamService
)
{
}
@@ -88,7 +90,7 @@ final class TeamController extends BaseApiController
#[Route(methods: ['DELETE'], path: '/{id}', name: 'delete_team', requirements: ['id' => '\d+'])]
public function deleteAction(Team $team): Response
{
$this->repository->deleteTeam($team);
$this->teamService->deleteTeam($team);
$view = new View(null, Response::HTTP_NO_CONTENT);
@@ -104,13 +106,13 @@ final class TeamController extends BaseApiController
#[Route(methods: ['POST'], path: '', name: 'post_team')]
public function postAction(Request $request): Response
{
$team = new Team('');
$team = $this->teamService->createNewTeam('');
$form = $this->createForm(TeamApiEditForm::class, $team);
$form->submit($request->request->all());
if ($form->isValid()) {
$this->repository->saveTeam($team);
$this->teamService->saveTeam($team);
$view = new View($team, 200);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -139,6 +141,8 @@ final class TeamController extends BaseApiController
$team->removeMember($member);
$this->repository->removeTeamMember($member);
}
// this fails, if we use the teamservice, because the validator
// complains about teams without members or teamleads
$this->repository->saveTeam($team);
}
@@ -154,7 +158,7 @@ final class TeamController extends BaseApiController
return $this->viewHandler->handle($view);
}
$this->repository->saveTeam($team);
$this->teamService->saveTeam($team);
$view = new View($team, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -178,7 +182,7 @@ final class TeamController extends BaseApiController
$team->addUser($member);
$this->repository->saveTeam($team);
$this->teamService->saveTeam($team);
$view = new View($team, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_ENTITY);
@@ -206,7 +210,7 @@ final class TeamController extends BaseApiController
$team->removeUser($member);
$this->repository->saveTeam($team);
$this->teamService->saveTeam($team);
$view = new View($team, Response::HTTP_OK);
$view->getContext()->setGroups(self::GROUPS_ENTITY);

View File

@@ -66,7 +66,7 @@ final class UserController extends BaseApiController
$query->setCurrentUser($this->getUser());
$visible = $paramFetcher->get('visible');
if (\is_string($visible) && $visible !== '') {
if (is_numeric($visible)) {
$query->setVisibility((int) $visible);
}

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.44.0';
public const VERSION = '2.45.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 24400;
public const VERSION_ID = 24500;
/**
* The software name
*/

View File

@@ -15,7 +15,6 @@ use App\Configuration\SystemConfiguration;
use App\Entity\Activity;
use App\Entity\ActivityRate;
use App\Entity\Project;
use App\Entity\Team;
use App\Event\ActivityDetailControllerEvent;
use App\Event\ActivityMetaDisplayEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
@@ -32,6 +31,7 @@ use App\Repository\Query\ActivityQuery;
use App\Repository\Query\TeamQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TeamRepository;
use App\User\TeamService;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Exception;
@@ -40,6 +40,7 @@ use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
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;
@@ -303,19 +304,24 @@ final class ActivityController extends AbstractController
#[Route(path: '/{id}/create_team', name: 'activity_team_create', methods: ['GET'])]
#[IsGranted('create_team')]
#[IsGranted('permissions', 'activity')]
public function createDefaultTeamAction(Activity $activity, TeamRepository $teamRepository): Response
public function createDefaultTeamAction(Activity $activity, TeamService $teamService): Response
{
$defaultTeam = $teamRepository->findOneBy(['name' => $activity->getName()]);
$name = $activity->getName();
if ($name === null) {
throw new BadRequestHttpException('Cannot create default team for activity with empty name: ' . $activity->getId());
}
$defaultTeam = $teamService->findTeamByName($name);
if (null === $defaultTeam) {
$defaultTeam = new Team($activity->getName());
$defaultTeam = $teamService->createNewTeam($name);
}
$defaultTeam->addTeamlead($this->getUser());
$defaultTeam->addActivity($activity);
try {
$teamRepository->saveTeam($defaultTeam);
$teamService->saveTeam($defaultTeam);
} catch (Exception $ex) {
$this->flashUpdateException($ex);
}

View File

@@ -14,7 +14,6 @@ use App\Customer\CustomerStatisticService;
use App\Entity\Customer;
use App\Entity\CustomerComment;
use App\Entity\CustomerRate;
use App\Entity\Team;
use App\Event\CustomerDetailControllerEvent;
use App\Event\CustomerMetaDisplayEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
@@ -35,6 +34,7 @@ use App\Repository\Query\TeamQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\Query\VisibilityInterface;
use App\Repository\TeamRepository;
use App\User\TeamService;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Psr\EventDispatcher\EventDispatcherInterface;
@@ -42,6 +42,7 @@ use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
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\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
@@ -239,19 +240,24 @@ final class CustomerController extends AbstractController
#[Route(path: '/{id}/create_team', name: 'customer_team_create', methods: ['GET'])]
#[IsGranted('create_team')]
#[IsGranted('permissions', 'customer')]
public function createDefaultTeamAction(Customer $customer, TeamRepository $teamRepository): Response
public function createDefaultTeamAction(Customer $customer, TeamService $teamService): Response
{
$defaultTeam = $teamRepository->findOneBy(['name' => $customer->getName()]);
$name = $customer->getName();
if ($name === null) {
throw new BadRequestHttpException('Cannot create default team for customer with empty name: ' . $customer->getId());
}
$defaultTeam = $teamService->findTeamByName($name);
if (null === $defaultTeam) {
$defaultTeam = new Team($customer->getName());
$defaultTeam = $teamService->createNewTeam($name);
}
$defaultTeam->addTeamlead($this->getUser());
$defaultTeam->addCustomer($customer);
try {
$teamRepository->saveTeam($defaultTeam);
$teamService->saveTeam($defaultTeam);
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
}

View File

@@ -14,7 +14,6 @@ use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\ProjectComment;
use App\Entity\ProjectRate;
use App\Entity\Team;
use App\Event\ProjectDetailControllerEvent;
use App\Event\ProjectMetaDisplayEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
@@ -38,6 +37,7 @@ use App\Repository\Query\TeamQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\Query\VisibilityInterface;
use App\Repository\TeamRepository;
use App\User\TeamService;
use App\Utils\Context;
use App\Utils\DataTable;
use App\Utils\PageSetup;
@@ -46,6 +46,7 @@ use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\Form\FormInterface;
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\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
@@ -267,19 +268,24 @@ final class ProjectController extends AbstractController
#[Route(path: '/{id}/create_team', name: 'project_team_create', methods: ['GET'])]
#[IsGranted('create_team')]
#[IsGranted('permissions', 'project')]
public function createDefaultTeamAction(Project $project, TeamRepository $teamRepository): Response
public function createDefaultTeamAction(Project $project, TeamService $teamService): Response
{
$defaultTeam = $teamRepository->findOneBy(['name' => $project->getName()]);
$name = $project->getName();
if ($name === null) {
throw new BadRequestHttpException('Cannot create default team for project with empty name: ' . $project->getId());
}
$defaultTeam = $teamService->findTeamByName($name);
if (null === $defaultTeam) {
$defaultTeam = new Team($project->getName());
$defaultTeam = $teamService->createNewTeam($name);
}
$defaultTeam->addTeamlead($this->getUser());
$defaultTeam->addProject($project);
try {
$teamRepository->saveTeam($defaultTeam);
$teamService->saveTeam($defaultTeam);
} catch (\Exception $ex) {
$this->flashUpdateException($ex);
}

View File

@@ -16,6 +16,7 @@ use App\Form\Type\CustomerType;
use App\Form\Type\ProjectType;
use App\Repository\Query\TeamQuery;
use App\Repository\TeamRepository;
use App\User\TeamService;
use App\Utils\DataTable;
use App\Utils\PageSetup;
use Symfony\Component\Form\Extension\Core\Type\FormType;
@@ -30,7 +31,10 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('view_team')]
final class TeamController extends AbstractController
{
public function __construct(private TeamRepository $repository)
public function __construct(
private readonly TeamRepository $repository,
private readonly TeamService $teamService,
)
{
}
@@ -85,7 +89,9 @@ final class TeamController extends AbstractController
#[IsGranted('create_team')]
public function createTeam(Request $request): Response
{
return $this->renderEditScreen(new Team(''), $request, true);
$team = $this->teamService->createNewTeam('');
return $this->renderEditScreen($team, $request, true);
}
#[Route(path: '/{id}/duplicate', name: 'team_duplicate', methods: ['GET', 'POST'])]
@@ -128,7 +134,7 @@ final class TeamController extends AbstractController
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->repository->saveTeam($team);
$this->teamService->saveTeam($team);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
@@ -167,7 +173,7 @@ final class TeamController extends AbstractController
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->repository->saveTeam($team);
$this->teamService->saveTeam($team);
$this->flashSuccess('action.update.success');
if ($create) {

View File

@@ -53,7 +53,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
#[Serializer\Expose]
#[Serializer\Groups(['Subresource', 'Expanded'])]
#[Serializer\Groups(['Expanded'])]
#[OA\Property(ref: '#/components/schemas/ProjectExpanded')]
private ?Project $project = null;
/**
@@ -96,7 +96,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget, CreatedAt
*/
#[ORM\OneToMany(mappedBy: 'activity', targetEntity: ActivityMeta::class, cascade: ['persist'])]
#[Serializer\Expose]
#[Serializer\Groups(['Activity'])]
#[Serializer\Groups(['Default'])]
#[Serializer\Type(name: 'array<App\Entity\ActivityMeta>')]
#[Serializer\SerializedName('metaFields')]
#[Serializer\Accessor(getter: 'getVisibleMetaFields')]

View File

@@ -18,7 +18,7 @@ use Symfony\Component\Validator\Constraints as Assert;
trait BudgetTrait
{
/**
* The total monetary budget, will be zero if not configured.
* The total monetary budget (default: 0).
*/
#[ORM\Column(name: 'budget', type: Types::FLOAT, nullable: false)]
#[Assert\Range(min: 0.00, max: 900000000000.00)]
@@ -28,7 +28,7 @@ trait BudgetTrait
#[Exporter\Expose(label: 'budget', type: 'float')]
private float $budget = 0.00;
/**
* The time budget in seconds, will be zero if not configured.
* The time budget in seconds (default: 0).
*/
#[ORM\Column(name: 'time_budget', type: Types::INTEGER, nullable: false)]
#[Assert\Range(min: 0, max: 2145600000)]
@@ -39,8 +39,8 @@ trait BudgetTrait
private int $timeBudget = 0;
/**
* The type of budget:
* - null = default / full time
* - month = monthly budget
* - null = default / full time
* - month = monthly budget
*/
#[ORM\Column(name: 'budget_type', type: Types::STRING, length: 10, nullable: true)]
#[Serializer\Expose]

View File

@@ -11,6 +11,7 @@ namespace App\Entity;
use App\Constants;
use App\Export\Annotation as Exporter;
use App\Utils\Color;
use App\Validator\Constraints as Constraints;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
@@ -22,8 +23,7 @@ trait ColorTrait
* The assigned color in HTML hex format, e.g. #dd1d00
*/
#[ORM\Column(name: 'color', type: Types::STRING, length: 7, nullable: true)]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[Serializer\Exclude]
#[Exporter\Expose(label: 'color')]
#[Constraints\HexColor]
private ?string $color = null;
@@ -46,4 +46,17 @@ trait ColorTrait
{
$this->color = $color;
}
abstract public function getName(): ?string;
/**
* Internal value: this color will never be empty and is generated by the tag name if not set explicit.
*/
#[Serializer\VirtualProperty]
#[Serializer\SerializedName('color')]
#[Serializer\Groups(['Default'])]
public function getColorSafe(): string
{
return $this->getColor() ?? (new Color())->getRandom($this->getName());
}
}

View File

@@ -122,19 +122,19 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[ORM\Column(name: 'phone', type: Types::STRING, length: 30, nullable: true)]
#[Assert\Length(max: 30)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer'])]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'phone')]
private ?string $phone = null;
#[ORM\Column(name: 'fax', type: Types::STRING, length: 30, nullable: true)]
#[Assert\Length(max: 30)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer'])]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'fax')]
private ?string $fax = null;
#[ORM\Column(name: 'mobile', type: Types::STRING, length: 30, nullable: true)]
#[Assert\Length(max: 30)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer'])]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'mobile')]
private ?string $mobile = null;
/**
@@ -149,7 +149,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[ORM\Column(name: 'homepage', type: Types::STRING, length: 100, nullable: true)]
#[Assert\Length(max: 100)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer'])]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'homepage')]
private ?string $homepage = null;
/**
@@ -160,7 +160,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[Assert\Timezone]
#[Assert\Length(max: 64)]
#[Serializer\Expose]
#[Serializer\Groups(['Customer'])]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'timezone')]
private ?string $timezone = null;
/**
@@ -170,7 +170,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget, CreatedAt
*/
#[ORM\OneToMany(mappedBy: 'customer', targetEntity: CustomerMeta::class, cascade: ['persist'])]
#[Serializer\Expose]
#[Serializer\Groups(['Customer'])]
#[Serializer\Groups(['Default'])]
#[Serializer\Type(name: 'array<App\Entity\CustomerMeta>')]
#[Serializer\SerializedName('metaFields')]
#[Serializer\Accessor(getter: 'getVisibleMetaFields')]

View File

@@ -60,7 +60,7 @@ class Invoice implements EntityWithMetaFields
private ?string $invoiceNumber = null;
#[ORM\Column(name: 'comment', type: Types::TEXT, nullable: true)]
#[Serializer\Expose]
#[Serializer\Groups(['Invoice'])]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'comment')]
private ?string $comment = null;
#[ORM\ManyToOne(targetEntity: Customer::class)]
@@ -79,8 +79,6 @@ class Invoice implements EntityWithMetaFields
private ?User $user = null;
#[ORM\Column(name: 'created_at', type: Types::DATETIME_MUTABLE, nullable: false)]
#[Assert\NotNull]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private ?\DateTime $createdAt = null;
#[ORM\Column(name: 'timezone', type: Types::STRING, length: 64, nullable: false)]
private ?string $timezone = null;
@@ -107,7 +105,7 @@ class Invoice implements EntityWithMetaFields
#[Assert\NotNull]
#[Assert\Range(min: 0, max: 999)]
#[Serializer\Expose]
#[Serializer\Groups(['Invoice'])]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(label: 'due_days', type: 'integer')]
private int $dueDays = 30;
#[ORM\Column(name: 'vat', type: Types::FLOAT, nullable: false)]
@@ -140,7 +138,7 @@ class Invoice implements EntityWithMetaFields
*/
#[ORM\OneToMany(mappedBy: 'invoice', targetEntity: InvoiceMeta::class, cascade: ['persist'])]
#[Serializer\Expose]
#[Serializer\Groups(['Invoice'])]
#[Serializer\Groups(['Default'])]
#[Serializer\Type(name: 'array<App\Entity\InvoiceMeta>')]
#[Serializer\SerializedName('metaFields')]
#[Serializer\Accessor(getter: 'getVisibleMetaFields')]
@@ -176,6 +174,9 @@ class Invoice implements EntityWithMetaFields
return $this->total;
}
#[Serializer\VirtualProperty]
#[Serializer\SerializedName('createdAt')]
#[Serializer\Groups(['Default'])]
#[Exporter\Expose(name: 'createdAt', label: 'date', type: 'datetime')]
public function getCreatedAt(): ?\DateTime
{
@@ -202,6 +203,9 @@ class Invoice implements EntityWithMetaFields
return $dueDate;
}
#[Serializer\VirtualProperty()]
#[Serializer\SerializedName('overdue')]
#[Serializer\Groups(['Default'])]
public function isOverdue(): bool
{
if (null === $this->getDueDate()) {

View File

@@ -56,7 +56,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
#[Serializer\Expose]
#[Serializer\Groups(['Subresource', 'Expanded'])]
#[Serializer\Groups(['Expanded'])]
#[OA\Property(ref: '#/components/schemas/Customer')]
private ?Customer $customer = null;
/**
@@ -85,7 +85,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
*/
#[ORM\Column(name: 'order_date', type: Types::DATETIME_MUTABLE, nullable: true)]
#[Serializer\Expose]
#[Serializer\Groups(['Project_Entity'])]
#[Serializer\Groups(['Default'])]
#[Serializer\Type(name: "DateTime<'Y-m-d'>")]
#[Serializer\Accessor(getter: 'getOrderDate')]
private ?\DateTime $orderDate = null;
@@ -141,7 +141,7 @@ class Project implements EntityWithMetaFields, EntityWithBudget, CreatedAt
*/
#[ORM\OneToMany(mappedBy: 'project', targetEntity: ProjectMeta::class, cascade: ['persist'])]
#[Serializer\Expose]
#[Serializer\Groups(['Project'])]
#[Serializer\Groups(['Default'])]
#[Serializer\Type(name: 'array<App\Entity\ProjectMeta>')]
#[Serializer\SerializedName('metaFields')]
#[Serializer\Accessor(getter: 'getVisibleMetaFields')]

View File

@@ -10,7 +10,6 @@
namespace App\Entity;
use App\Repository\TagRepository;
use App\Utils\Color;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
@@ -21,9 +20,9 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\UniqueConstraint(columns: ['name'])]
#[ORM\Entity(repositoryClass: TagRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[ORM\Index(columns: ['visible'])]
#[UniqueEntity('name')]
#[Serializer\ExclusionPolicy('all')]
#[Serializer\VirtualProperty('ColorSafe', exp: 'object.getColorSafe()', options: [new Serializer\SerializedName('color-safe'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Default'])])]
class Tag
{
/**
@@ -88,9 +87,4 @@ class Tag
{
return $this->getName();
}
public function getColorSafe(): string
{
return $this->getColor() ?? (new Color())->getRandom($this->getName());
}
}

View File

@@ -54,23 +54,23 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
use ModifiedTrait;
/**
* Category: Normal work-time (default category)
* @deprecated since 2.45
*/
public const WORK = 'work';
/**
* Category: Holiday
* @deprecated since 2.45
*/
public const HOLIDAY = 'holiday';
/**
* Category: Sickness
* @deprecated since 2.45
*/
public const SICKNESS = 'sickness';
/**
* Category: Parental leave
* @deprecated since 2.45
*/
public const PARENTAL = 'parental';
/**
* Category: Overtime reduction
* @deprecated since 2.45
*/
public const OVERTIME = 'overtime';
@@ -132,27 +132,29 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private ?int $duration = 0;
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
#[ORM\Column(name: 'break', type: Types::INTEGER, nullable: true)]
private ?int $break = 0;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: '`user`', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
#[Serializer\Expose]
#[Serializer\Groups(['Subresource', 'Expanded'])]
#[Serializer\Groups(['Expanded'])]
#[OA\Property(ref: '#/components/schemas/User')]
private ?User $user = null;
#[ORM\ManyToOne(targetEntity: Activity::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
#[Serializer\Expose]
#[Serializer\Groups(['Subresource', 'Expanded'])]
#[Serializer\Groups(['Expanded'])]
#[OA\Property(ref: '#/components/schemas/ActivityExpanded')]
private ?Activity $activity = null;
#[ORM\ManyToOne(targetEntity: Project::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
#[Serializer\Expose]
#[Serializer\Groups(['Subresource', 'Expanded'])]
#[Serializer\Groups(['Expanded'])]
#[OA\Property(ref: '#/components/schemas/ProjectExpanded')]
private ?Project $project = null;
#[ORM\Column(name: 'description', type: Types::TEXT, nullable: true)]
@@ -196,7 +198,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
private ?string $billableMode = self::BILLABLE_DEFAULT;
#[ORM\Column(name: 'category', type: Types::STRING, length: 10, nullable: false, options: ['default' => 'work'])]
#[Assert\NotNull]
private ?string $category = self::WORK;
private ?string $category = 'work';
/**
* Tags
*
@@ -503,11 +505,7 @@ class Timesheet implements EntityWithMetaFields, ExportableItem, ModifiedAt
public function setCategory(string $category): Timesheet
{
$allowed = [self::WORK, self::HOLIDAY, self::SICKNESS, self::PARENTAL, self::OVERTIME];
if (!\in_array($category, $allowed)) {
throw new \InvalidArgumentException(\sprintf('Invalid timesheet category "%s" given, expected one of: %s', $category, implode(', ', $allowed)));
}
@trigger_error('Timesheet::setCategory() is deprecated.', E_USER_DEPRECATED);
$this->category = $category;

View File

@@ -169,6 +169,8 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[Assert\NotBlank(groups: ['Registration', 'UserCreate', 'Profile'])]
#[Assert\Length(min: 2, max: 180)]
#[Assert\Email(mode: 'html5', groups: ['Registration', 'UserCreate', 'Profile'])]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private ?string $email = null;
#[ORM\Column(name: 'account', type: Types::STRING, length: 30, nullable: true)]
#[Assert\Length(max: 30)]
@@ -219,6 +221,8 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[ORM\Column(name: 'totp_enabled', type: Types::BOOLEAN, nullable: false, options: ['default' => false])]
private bool $totpEnabled = false;
#[ORM\Column(name: 'system_account', type: Types::BOOLEAN, nullable: false, options: ['default' => false])]
#[Serializer\Expose]
#[Serializer\Groups(['Default'])]
private bool $systemAccount = false;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
@@ -352,6 +356,9 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
$all = [];
foreach ($this->preferences as $preference) {
if ($preference->getName() === null || $preference->getName()[0] === '_') {
continue;
}
if ($preference->isEnabled() && !\in_array($preference->getName(), $skip)) {
$all[] = $preference;
}
@@ -370,7 +377,6 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
/**
* @param iterable<UserPreference> $preferences
* @return User
*/
public function setPreferences(iterable $preferences): User
{
@@ -384,7 +390,6 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
}
/**
* @param string $name
* @param bool|int|string|float|null $value
*/
public function setPreferenceValue(string $name, $value = null): void
@@ -419,7 +424,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
*/
#[Serializer\VirtualProperty]
#[Serializer\SerializedName('locale')]
#[Serializer\Groups(['User_Entity'])]
#[Serializer\Groups(['Default'])]
#[OA\Property(type: 'string')]
public function getLocale(): string
{
@@ -434,7 +439,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[Serializer\VirtualProperty]
#[Serializer\SerializedName('timezone')]
#[Serializer\Groups(['User_Entity'])]
#[Serializer\Groups(['Default'])]
#[OA\Property(type: 'string')]
public function getTimezone(): string
{
@@ -442,11 +447,11 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
}
/**
* The locale used for translations
* The locale used for translating the UI
*/
#[Serializer\VirtualProperty]
#[Serializer\SerializedName('language')]
#[Serializer\Groups(['User_Entity'])]
#[Serializer\Groups(['Default'])]
#[OA\Property(type: 'string')]
public function getLanguage(): string
{

View File

@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Entity\Team;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Base event class triggered for Team manipulations.
*/
abstract class AbstractTeamEvent extends Event
{
public function __construct(private readonly Team $team)
{
}
public function getTeam(): Team
{
return $this->team;
}
}

View 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 for new team instances, which might or might not be saved.
*/
final class TeamCreateEvent extends AbstractTeamEvent
{
}

View 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;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'team.created', description: 'Triggered after a team was created', payload: 'object.getTeam()')]
final class TeamCreatePostEvent extends AbstractTeamEvent
{
}

View 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 for team instances, which are just about to being saved.
*/
final class TeamCreatePreEvent extends AbstractTeamEvent
{
}

View 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;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'team.deleted', description: 'Triggered after a team was deleted', payload: 'object.getTeam()')]
final class TeamDeleteEvent extends AbstractTeamEvent
{
}

View 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;
use App\Webhook\Attribute\AsWebhook;
#[AsWebhook(name: 'team.updated', description: 'Triggered after a team was updated', payload: 'object.getTeam()')]
final class TeamUpdatePostEvent extends AbstractTeamEvent
{
}

View 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 for team instances, which are just about to being updated.
*/
final class TeamUpdatePreEvent extends AbstractTeamEvent
{
}

View File

@@ -29,6 +29,8 @@ class ThemeEvent extends Event
private string $content = '';
/**
* User is nullable, because theme events are triggered on anonymous pages, like "Login" or "Kiosk"
*
* @param array<string, mixed|array<mixed>> $payload
*/
public function __construct(private readonly ?User $user = null, protected array $payload = [])

View File

@@ -17,6 +17,8 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* Adds the links in the user profile dropdown in the template on each page.
*
* @internal
*/
class UserDetailsSubscriber implements EventSubscriberInterface

View File

@@ -105,6 +105,10 @@ class TimesheetEditForm extends AbstractType
$this->addDuration($builder, $options, (!$options['allow_begin_datetime'] || !$options['allow_end_datetime']), $isNew);
}
if ($this->systemConfiguration->isBreakTimeEnabled()) {
$builder->add('break', DurationType::class, ['label' => 'break', 'required' => false, 'icon' => 'break']);
}
// -----------------------------------------------------
$query = new CustomerFormTypeQuery($customer);
$query->setUser($options['user']); // @phpstan-ignore-line
@@ -328,10 +332,6 @@ class TimesheetEditForm extends AbstractType
$builder->add('duration', DurationType::class, $durationOptions);
if ($this->systemConfiguration->isBreakTimeEnabled()) {
$builder->add('break', DurationType::class, ['label' => 'break', 'required' => false, 'icon' => 'break']);
}
$builder->addEventListener(
FormEvents::POST_SET_DATA,
function (FormEvent $event): void {

View File

@@ -25,10 +25,12 @@ final class CalendarToolbarForm extends AbstractType
'view_timezone' => $options['timezone'],
]);
$builder->add('view', CalendarViewType::class, []);
$builder->add('user', UserType::class, [
'required' => false,
'attr' => ['onchange' => 'this.form.submit()']
]);
if ($options['change_user']) {
$builder->add('user', UserType::class, [
'required' => false,
'attr' => ['onchange' => 'this.form.submit()']
]);
}
}
public function configureOptions(OptionsResolver $resolver): void

View File

@@ -39,7 +39,7 @@ final class TagsType extends AbstractType
public function getParent(): string
{
if ($this->count === null) {
$this->count = $this->repository->count([]);
$this->count = $this->repository->count(['visible' => true]);
}
if ($this->count > self::MAX_AMOUNT_SELECT) {

View File

@@ -75,6 +75,8 @@ 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),
// prepare optional field with empty string
'entry.activity' => '',
];
if ($begin !== null) {

View File

@@ -14,6 +14,7 @@ use App\Form\Type\MonthPickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -41,6 +42,8 @@ final class ProjectDateRangeForm extends AbstractType
'label' => 'includeNoWork',
]);
$builder->add('view', HiddenType::class);
$builder->add('budgetType', ChoiceType::class, [
'placeholder' => null,
'required' => false,

View File

@@ -18,6 +18,7 @@ final class ProjectDateRangeQuery
private ?Customer $customer = null;
private bool $includeNoWork = false;
private ?string $budgetType = null;
private ?string $view = '0';
public function __construct(\DateTime $month, private User $user)
{
@@ -83,4 +84,14 @@ final class ProjectDateRangeQuery
{
$this->budgetType = $budgetType;
}
public function getView(): ?string
{
return $this->view;
}
public function setView(?string $view): void
{
$this->view = $view;
}
}

View File

@@ -9,7 +9,18 @@
namespace App\User;
use App\Entity\Team;
use App\Event\TeamCreateEvent;
use App\Event\TeamCreatePostEvent;
use App\Event\TeamCreatePreEvent;
use App\Event\TeamDeleteEvent;
use App\Event\TeamUpdatePostEvent;
use App\Event\TeamUpdatePreEvent;
use App\Repository\TeamRepository;
use App\Validator\ValidationFailedException;
use InvalidArgumentException;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class TeamService
{
@@ -18,10 +29,19 @@ final class TeamService
*/
private array $cache = [];
public function __construct(private TeamRepository $repository)
public function __construct(
private readonly TeamRepository $repository,
private readonly ValidatorInterface $validator,
private readonly EventDispatcherInterface $dispatcher,
)
{
}
public function findTeamByName(string $name): ?Team
{
return $this->repository->findOneBy(['name' => $name]);
}
public function countTeams(): int
{
if (!\array_key_exists('count', $this->cache)) {
@@ -31,8 +51,67 @@ final class TeamService
return $this->cache['count'];
}
public function createNewTeam(string $name): Team
{
$team = new Team($name);
$this->dispatcher->dispatch(new TeamCreateEvent($team));
return $team;
}
public function saveTeam(Team $team): Team
{
if(null === $team->getId()) {
// invalidate cache only on new teams
return $this->saveNewTeam($team);
}
return $this->updateTeam($team);
}
private function saveNewTeam(Team $team): Team
{
if (null !== $team->getId()) {
throw new InvalidArgumentException('Cannot create team, already persisted');
}
$this->validateTeam($team);
$this->dispatcher->dispatch(new TeamCreatePreEvent($team));
$this->repository->saveTeam($team);
$this->dispatcher->dispatch(new TeamCreatePostEvent($team));
return $team;
}
public function hasTeams(): bool
{
return $this->countTeams() > 0;
}
private function validateTeam(Team $team): void
{
$errors = $this->validator->validate($team);
if ($errors->count() > 0) {
throw new ValidationFailedException($errors);
}
}
private function updateTeam(Team $team): Team
{
$this->validateTeam($team);
$this->dispatcher->dispatch(new TeamUpdatePreEvent($team));
$this->repository->saveTeam($team);
$this->dispatcher->dispatch(new TeamUpdatePostEvent($team));
return $team;
}
public function deleteTeam(Team $delete): void
{
$this->dispatcher->dispatch(new TeamDeleteEvent($delete));
$this->repository->deleteTeam($delete);
}
}

View File

@@ -95,7 +95,7 @@ final class ParsedownExtension extends Parsedown
return null;
}
protected function blockTable($Line, array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter
protected function blockTable($Line, ?array $Block = null) // @phpstan-ignore missingType.return,missingType.iterableValue,missingType.parameter
{
$Block = parent::blockTable($Line, $Block);

View File

@@ -11,7 +11,7 @@ namespace App\Validator;
final class ValidationException extends \RuntimeException
{
public function __construct(string $message = null)
public function __construct(?string $message = null)
{
if ($message === null) {
$message = 'Validation Failed';

View File

@@ -181,7 +181,7 @@ final class WorkingTimeService
return $year->getMonth($monthDate);
}
// deprecated 3.0 remove $user, fetch from $month->getUser() instead
// FIXME deprecated 3.0 remove $user, fetch from $month->getUser() instead
public function approveMonth(User $user, Month $month, \DateTimeInterface $approvalDate, User $approvedBy): void
{
foreach ($month->getDays() as $day) {