<?php
declare(strict_types=1);
namespace App\Entity\TimeControl;
use ApiPlatform\Core\Annotation\{ApiFilter, ApiProperty, ApiResource};
use ApiPlatform\Core\Bridge\Doctrine\Orm\{Filter\DateFilter, Filter\ExistsFilter, Filter\OrderFilter, Filter\SearchFilter};
use App\Dto\{DeveloperTimeOutput, TimeEntryApproveInput, TimeEntryBreakInput, TimeEntryFixInput, TimeEntryFixOutput};
use App\Entity\{Profile, Project\Project, User};
use App\Interfaces\UuidKeyInterface;
use App\Repository\TimeEntryRepository;
use App\Service\DateTimeHelper;
use App\Traits\TimeEntryTrait;
use App\Traits\TimestampableEntity;
use App\Traits\UuidEntityTrait;
use App\Validator\{TimeEntryBlocked as AssertBlocked, TimeEntryLength as AssertTimeEntryLength};
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\{Constraints as Assert, Context\ExecutionContextInterface};
/**
* @see \App\EventSubscriber\TimeEntryEventSubscriber
*/
#[ApiResource(
collectionOperations: [
'get',
'post' => [
'security_post_denormalize' => "is_granted('ENTRY_CREATE', object)",
'security_message' => 'Only developer himself or Project Manager can create Time entries',
'validation_groups' => [TimeEntry::VG_CREATE],
],
'developers' => [
'method' => 'GET',
'path' => 'time-entries/developers',
'openapi_context' => ['summary' => 'Get time for developers'],
'output' => DeveloperTimeOutput::class,
],
'open' => [
'method' => 'POST',
'security_post_denormalize' => "is_granted('ENTRY_OPEN', object)",
'security_message' => 'Unable to open time entry for another user',
'path' => 'time-entries/open',
'openapi_context' => ['summary' => 'Open Time entry'],
'validation_groups' => [TimeEntry::VG_OPEN],
],
],
itemOperations: [
'get',
'put' => ['security' => "is_granted('ENTRY_MODIFY', object)"],
'delete' => [
'security' => "is_granted('ENTRY_DELETE', object)",
'security_message' => 'No access to Time Entry delete, or TE is Blocked',
],
'patch' => [
'security' => "is_granted('ENTRY_MODIFY', object)",
'security_message' => 'No access to Time Entry modification',
],
TimeEntry::ITEM_CLOSE => [
'method' => 'PATCH',
'path' => 'time-entries/{id}/close',
'security' => "is_granted('ENTRY_MODIFY', object)",
'openapi_context' => ['summary' => 'Closes current Time entry'],
'validation_groups' => [TimeEntry::VG_OPEN],
],
TimeEntry::ITEM_BREAK => [
'method' => 'PATCH',
'path' => 'time-entries/{id}/break',
'security' => "is_granted('ENTRY_MODIFY', object)",
'input' => TimeEntryBreakInput::class,
'openapi_context' => ['summary' => 'Break Time entry'],
],
TimeEntry::ITEM_APPROVED => [
'method' => 'PATCH',
'path' => 'time-entries/{id}/approved',
'security' => "is_granted('ENTRY_APPROVED', object)",
'input' => TimeEntryApproveInput::class,
'openapi_context' => ['summary' => 'Approved flag for TimeEntry is set by project manager or higher'],
],
TimeEntry::PATCH_TE_OPERATION => [
'method' => 'PATCH',
'path' => 'time-entries/{id}/fix',
'input' => TimeEntryFixInput::class,
'output' => TimeEntryFixOutput::class,
'security' => "is_granted('ROLE_USER_FIXER')",
'openapi_context' => ['summary' => 'Set fix $ approved for time-entries'],
],
],
attributes: ['order' => ['start' => 'ASC']],
)]
#[ApiFilter(DateFilter::class, properties: [
'start' => DateFilter::EXCLUDE_NULL,
'end' => DateFilter::INCLUDE_NULL_BEFORE,
])]
#[ApiFilter(SearchFilter::class, properties: [
'user.slug' => 'exact',
'project.slug' => 'exact',
])]
#[ApiFilter(ExistsFilter::class, properties: ['end'])]
#[ApiFilter(OrderFilter::class, properties: [
'start' => 'ASC',
'end' => 'ASC',
'user.slug' => 'ASC',
'project.slug' => 'ASC',
])]
#[AssertBlocked()]
#[AssertTimeEntryLength(minLength: AssertTimeEntryLength::MIN_TIME_LENGTH, maxlength: AssertTimeEntryLength::MAX_TIME_LENGTH)]
#[ORM\Entity(repositoryClass: TimeEntryRepository::class)]
#[ORM\Table]
#[ORM\Index(name: 'idx_time_entry_timer_start', columns: ['timer_start'])]
#[ORM\Index(name: 'idx_time_entry_timer_end', columns: ['timer_end'])]
#[ORM\Index(name: 'idx_time_entry_is_fixed', columns: ['is_fixed'])]
class TimeEntry implements UuidKeyInterface, \Stringable
{
use UuidEntityTrait;
use TimestampableEntity;
use TimeEntryTrait;
public const ITEM_CLOSE = 'close';
public const ITEM_BREAK = 'break';
public const ITEM_APPROVED = 'approved';
public const VG_CREATE = 'create';
public const VG_UPDATE = 'update';
public const VG_OPEN = 'open';
public const VG_CLOSE = 'close';
public const PATCH_TE_OPERATION = 'patch-te';
public function __construct()
{
$this->createdAt = new \DateTime();
}
#[Assert\NotNull(groups: [self::VG_CREATE])]
#[ApiProperty(description: 'начало', openapiContext: ['format' => 'date-time'])]
#[Groups(['TimeEntry:read', 'TimeEntry:write', 'Project:read'])]
#[ORM\Column(type: 'datetime', nullable: false, name: 'timer_start')]
private ?\DateTimeInterface $start = null;
#[ApiProperty(description: 'конец', openapiContext: ['format' => 'date-time'])]
#[Groups(['TimeEntry:read', 'TimeEntry:write', 'Project:read'])]
#[Assert\NotNull(groups: [self::VG_CLOSE, self::VG_CREATE])]
#[ORM\Column(type: 'datetime', nullable: true, name: 'timer_end')]
private ?\DateTimeInterface $end = null;
#[Assert\Valid]
#[Groups(['TimeEntry:read'])]
#[ORM\ManyToOne(targetEntity: 'App\Entity\User', inversedBy: 'timeEntries', cascade: ['persist'])]
private ?User $user = null;
#[Groups(['TimeEntry:read'])]
#[ORM\ManyToOne(targetEntity: 'App\Entity\Project\Project', inversedBy: 'timeEntries', cascade: ['persist'])]
#[ORM\JoinColumn(nullable: true)]
private ?Project $project = null;
#[Groups(['TimeEntry:read', 'TimeEntry:write', 'Project:read'])]
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[Groups(['TimeEntry:read', 'TimeEntry:write', 'Project:read'])]
#[ORM\Column(type: 'string', nullable: true)]
private ?string $title = null;
#[Assert\Url]
#[Groups(['TimeEntry:read', 'TimeEntry:write', 'Project:read'])]
#[ORM\Column(type: 'string', nullable: true)]
private ?string $link = null;
#[Groups(['TimeEntry:read', 'TimeEntry:write', 'Project:read'])]
#[Assert\Valid(groups: [self::VG_CREATE, self::VG_UPDATE])]
#[Assert\NotNull(groups: [self::VG_CREATE, self::VG_UPDATE])]
#[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'timeEntries', cascade: ['persist'], fetch: 'EAGER')]
private ?Task $task = null;
#[Groups(['TimeEntry:read', 'TimeEntry:write', 'Project:read'])]
#[ApiProperty(description: 'защищена от изменений?')]
#[ORM\Column(type: 'boolean', options: ['default' => false, 'comment' => 'Change prohibition'])]
private bool $isFixed = false;
#[ORM\Column(type: 'datetimetz', nullable: true, name: 'timer_start_absolute')]
private ?\DateTimeInterface $startAbs = null;
#[ORM\Column(type: 'datetimetz', nullable: true, name: 'timer_end_absolute')]
private ?\DateTimeInterface $endAbs = null;
#[Groups(['TimeEntry:read', 'Project:read'])]
#[ApiProperty(description: 'подтверждение', deprecationReason: 'состояние подтверждения доступно в isApproved')]
#[ORM\OneToOne(targetEntity: TimeEntryValidation::class, mappedBy: 'timeEntry', fetch: 'EAGER', cascade: ['persist', 'remove'])]
private ?TimeEntryValidation $timeEntryValidation = null;
#[ORM\Column(type: 'datetime', nullable: true)]
private ?\DateTimeInterface $deletedAt = null;
public function __toString(): string
{
$from = $this->start !== null ? $this->start->format(\DateTimeInterface::ATOM) : 'Not defined';
$to = $this->end !== null ? $this->end->format(\DateTimeInterface::ATOM) : 'Not defined';
$username = 'Unknown user';
$user = $this->task?->getUser() ?? $this->user;
if ($user instanceof User) {
$username = $user->getUsername();
if (($profile = $user->getProfile()) instanceof Profile) {
$username = $profile->getPerson()->getFullName();
}
}
$taskTitle = 'Unknown task';
if (($task = $this->task) instanceof Task) {
$taskTitle = \sprintf('%s – %s', $task->getTitle() ?? 'Unknown task', $task->getProject()?->getTitle() ?? 'Unknown project');
}
return \sprintf('%s – %s (%s, %s)', $from, $to, $taskTitle, $username);
}
#[Assert\Callback]
public function validate(ExecutionContextInterface $context): void
{
$maxAllowedDate = new \DateTime('now +12hour', new \DateTimeZone('UTC'));
if ($this->start > $maxAllowedDate) {
$context->buildViolation('Start date {{ start }} cannot be later than {{ now }}')
->setParameter('{{ start }}', $this->startAbs?->format(\DateTimeInterface::ATOM))
->setParameter('{{ now }}', $maxAllowedDate->format(\DateTimeInterface::ATOM))
->atPath('start')
->addViolation();
}
if (isset($this->end)) {
if ($this->end > $maxAllowedDate) {
$context->buildViolation('End date {{ end }} cannot be later than {{ now }}')
->setParameter('{{ end }}', $this->endAbs?->format(\DateTimeInterface::ATOM))
->setParameter('{{ now }}', $maxAllowedDate->format(\DateTimeInterface::ATOM))
->atPath('end')
->addViolation();
}
if ($this->start >= $this->end) {
$context->buildViolation('End of period {{ end }} must be later than start {{ start }}')
->setParameter('{{ start }}', $this->startAbs?->format(\DateTimeInterface::ATOM))
->setParameter('{{ end }}', $this->endAbs?->format(\DateTimeInterface::ATOM))
->atPath('start')
->addViolation();
}
}
}
public function getStart(): ?\DateTimeInterface
{
return DateTimeHelper::truncateSeconds($this->start);
}
public function setStart(\DateTimeInterface $start): self
{
$datetime = DateTimeHelper::truncateSeconds($start);
\is_null($datetime) ? $this->setStartAbs($start) : $this->setStartAbs($datetime);
$this->start = $datetime;
return $this;
}
public function getEnd(): ?\DateTimeInterface
{
return DateTimeHelper::truncateSeconds($this->end);
}
public function setEnd(\DateTimeInterface $datetime = null): self
{
$datetime = DateTimeHelper::truncateSeconds($datetime);
$this->setEndAbs($datetime);
$this->end = \is_null($datetime) ? $datetime : DateTimeHelper::recreateDateTimeWithTimeZoneUTC($datetime);
return $this;
}
public function getLength(): int
{
return $this->getTimeEntryMinutes($this);
}
/**
* длительность в минутах.
*/
#[Groups(['TimeEntry:read', 'Project:read'])]
#[ApiProperty(description: 'длительность в минутах')]
public function getDuration(): int
{
return $this->getTimeEntryMinutes($this);
}
#[Groups(['TimeEntry:read', 'Project:read'])]
#[ApiProperty(description: 'подтвержденная длительность в минутах')]
public function getTimeMinute(): int
{
return $this->getIsApproved() ? $this->getTimeEntryMinutes($this) : 0;
}
#[Groups(['TimeEntry:read', 'Project:read'])]
#[ApiProperty(description: 'описание задачи')]
public function getTaskDescription(): ?string
{
return $this->getTask()?->getDescription();
}
public function getDescription(): ?string
{
return $this->description ?? $this->getTask()?->getDescription();
}
public function setDescription(?string $description): self
{
$this->description = $description;
return $this;
}
public function getTitle(): ?string
{
return $this->title ?? $this->getTask()?->getTitle();
}
public function setTitle(?string $title): self
{
$this->title = $title;
return $this;
}
public function getLink(): ?string
{
return $this->link ?? $this->getTask()?->getLink();
}
public function setLink(?string $link): self
{
$this->link = $link;
return $this;
}
public function getUser(): ?User
{
return $this->getTask()?->getUser() ?? $this->user;
}
public function setUser(?User $user): self
{
$this->user = $user;
if ($this->task instanceof Task) {
$this->task->setUser($user);
}
return $this;
}
public function getProject(): ?Project
{
return $this->getTask()?->getProject() ?? $this->project;
}
public function setProject(?Project $project): self
{
$this->project = $project;
if ($this->task instanceof Task) {
$this->task->setProject($project);
}
return $this;
}
public function getProjectName(): string
{
return $this->getProject()?->getTitle() ?? 'Unknown';
}
public function getTask(): ?Task
{
return $this->task;
}
public function setTask(?Task $task): TimeEntry
{
$this->task = $task;
return $this;
}
public function getIsFixed(): bool
{
return $this->isFixed;
}
public function setIsFixed(bool $isFixed): self
{
$this->isFixed = $isFixed;
return $this;
}
/**
* подтверждена?
*/
#[Groups(['TimeEntry:read', 'Project:read'])]
#[ApiProperty(description: 'подтверждена?')]
public function getIsApproved(): bool
{
return $this->getTimeEntryValidation()?->getIsApproved() ?? false;
}
public function getStartAbs(): ?\DateTimeInterface
{
return $this->startAbs;
}
private function setStartAbs(\DateTimeInterface $start): self
{
$this->startAbs = clone $start;
return $this;
}
public function getEndAbs(): ?\DateTimeInterface
{
return $this->endAbs;
}
private function setEndAbs(?\DateTimeInterface $end): self
{
$this->endAbs = \is_null($end) ? $end : clone $end;
return $this;
}
public function getTimeEntryValidation(): ?TimeEntryValidation
{
return $this->timeEntryValidation;
}
public function setTimeEntryValidation(?TimeEntryValidation $timeEntryValidation): self
{
// set the owning side of the relation if necessary
if ($timeEntryValidation && $timeEntryValidation->getTimeEntry() !== $this) {
$timeEntryValidation->setTimeEntry($this);
}
$this->timeEntryValidation = $timeEntryValidation;
return $this;
}
public function getDeletedAt(): ?\DateTimeInterface
{
return $this->deletedAt;
}
public function setDeletedAt(?\DateTimeInterface $deletedAt): void
{
$this->deletedAt = $deletedAt;
}
}