added annotation based exporter (#1831)

* added user exporter
* added project exporter
* added customer exporter
* added activity exporter
This commit is contained in:
Kevin Papst
2020-07-21 03:37:51 +02:00
committed by GitHub
parent 93a23f3fa3
commit aee063bb3c
79 changed files with 2980 additions and 215 deletions

View File

@@ -16,6 +16,9 @@ use App\Entity\MetaTableTypeInterface;
use App\Entity\Project;
use App\Event\ActivityMetaDefinitionEvent;
use App\Event\ActivityMetaDisplayEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Form\ActivityEditForm;
use App\Form\ActivityRateForm;
use App\Form\Toolbar\ActivityToolbarForm;
@@ -256,6 +259,34 @@ final class ActivityController extends AbstractController
);
}
/**
* @Route(path="/export", name="activity_export", methods={"GET"})
*/
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter)
{
$query = new ActivityQuery();
$query->setCurrentUser($this->getUser());
$form = $this->getToolbarForm($query);
$form->setData($query);
$form->submit($request->query->all(), false);
if (!$form->isValid()) {
$query->resetByFormError($form->getErrors());
}
$entries = $this->repository->getActivitiesForQuery($query);
$spreadsheet = $exporter->export(
Activity::class,
$entries,
new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT)
);
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-activities');
return $writer->getFileResponse($spreadsheet);
}
/**
* @param Activity $activity
* @param Request $request

View File

@@ -14,10 +14,12 @@ use App\Entity\Customer;
use App\Entity\CustomerComment;
use App\Entity\CustomerRate;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Rate;
use App\Entity\Team;
use App\Event\CustomerMetaDefinitionEvent;
use App\Event\CustomerMetaDisplayEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Form\CustomerCommentForm;
use App\Form\CustomerEditForm;
use App\Form\CustomerRateForm;
@@ -407,6 +409,34 @@ final class CustomerController extends AbstractController
]);
}
/**
* @Route(path="/export", name="customer_export", methods={"GET"})
*/
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter)
{
$query = new CustomerQuery();
$query->setCurrentUser($this->getUser());
$form = $this->getToolbarForm($query);
$form->setData($query);
$form->submit($request->query->all(), false);
if (!$form->isValid()) {
$query->resetByFormError($form->getErrors());
}
$entries = $this->repository->getCustomersForQuery($query);
$spreadsheet = $exporter->export(
Customer::class,
$entries,
new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::EXPORT)
);
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-customers');
return $writer->getFileResponse($spreadsheet);
}
/**
* @param Customer $customer
* @param Request $request

View File

@@ -19,6 +19,9 @@ use App\Entity\Rate;
use App\Entity\Team;
use App\Event\ProjectMetaDefinitionEvent;
use App\Event\ProjectMetaDisplayEvent;
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Form\ProjectCommentForm;
use App\Form\ProjectEditForm;
use App\Form\ProjectRateForm;
@@ -418,6 +421,34 @@ final class ProjectController extends AbstractController
]);
}
/**
* @Route(path="/export", name="project_export", methods={"GET"})
*/
public function exportAction(Request $request, EntityWithMetaFieldsExporter $exporter)
{
$query = new ProjectQuery();
$query->setCurrentUser($this->getUser());
$form = $this->getToolbarForm($query);
$form->setData($query);
$form->submit($request->query->all(), false);
if (!$form->isValid()) {
$query->resetByFormError($form->getErrors());
}
$entries = $this->repository->getProjectsForQuery($query);
$spreadsheet = $exporter->export(
Project::class,
$entries,
new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT)
);
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-projects');
return $writer->getFileResponse($spreadsheet);
}
/**
* @param Project $project
* @param Request $request

View File

@@ -12,6 +12,9 @@ namespace App\Controller;
use App\Configuration\FormConfiguration;
use App\Entity\User;
use App\Event\UserPreferenceDisplayEvent;
use App\Export\Spreadsheet\UserExporter;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
use App\Form\Toolbar\UserToolbarForm;
use App\Form\UserCreateType;
use App\Repository\Query\UserQuery;
@@ -65,7 +68,6 @@ final class UserController extends AbstractController
/**
* @Route(path="/", defaults={"page": 1}, name="admin_user", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_user_paginated", methods={"GET"})
* @Security("is_granted('view_user')")
*/
public function indexAction($page, Request $request): Response
{
@@ -191,6 +193,34 @@ final class UserController extends AbstractController
);
}
/**
* @Route(path="/export", name="user_export", methods={"GET"})
* @Security("is_granted('view_user')")
*/
public function exportAction(Request $request, UserExporter $exporter)
{
$query = new UserQuery();
$query->setCurrentUser($this->getUser());
$form = $this->getToolbarForm($query);
$form->setData($query);
$form->submit($request->query->all(), false);
if (!$form->isValid()) {
$query->resetByFormError($form->getErrors());
}
$entries = $this->getRepository()->getUsersForQuery($query);
$spreadsheet = $exporter->export(
$entries,
new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT)
);
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-users');
return $writer->getFileResponse($spreadsheet);
}
protected function getToolbarForm(UserQuery $query): FormInterface
{
return $this->createForm(UserToolbarForm::class, $query, [

View File

@@ -9,6 +9,7 @@
namespace App\Entity;
use App\Export\Annotation as Exporter;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -44,6 +45,10 @@ use Symfony\Component\Validator\Constraints as Assert;
* @Serializer\Groups({"Default"})
* }
* )
*
* @Exporter\Order({"id", "name", "project", "budget", "timeBudget", "color", "visible", "comment"})
* @Exporter\Expose("project", label="label.project", exp="object.getProject() === null ? null : object.getProject().getName()")
* @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array")
*/
class Activity implements EntityWithMetaFields
{
@@ -55,6 +60,8 @@ class Activity implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.id", type="integer")
*
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
@@ -75,6 +82,8 @@ class Activity implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.name")
*
* @ORM\Column(name="name", type="string", length=150, nullable=false)
* @Assert\NotBlank()
* @Assert\Length(min=2, max=150, allowEmptyString=false)
@@ -88,6 +97,8 @@ class Activity implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Activity_Entity"})
*
* @Exporter\Expose(label="label.comment")
*
* @ORM\Column(name="comment", type="text", nullable=true)
*/
private $comment;
@@ -99,6 +110,8 @@ class Activity implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.visible", type="boolean")
*
* @ORM\Column(name="visible", type="boolean", nullable=false, options={"default": true})
* @Assert\NotNull()
*/
@@ -115,6 +128,8 @@ class Activity implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Activity_Entity"})
*
* @ Exporter\Expose(label="label.budget")
*
* @ORM\Column(name="budget", type="float", nullable=false)
* @Assert\NotNull()
*/
@@ -127,6 +142,8 @@ class Activity implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Activity_Entity"})
*
* @ Exporter\Expose(label="label.timeBudget", type="duration")
*
* @ORM\Column(name="time_budget", type="integer", nullable=false)
* @Assert\NotNull()
*/

View File

@@ -9,6 +9,7 @@
namespace App\Entity;
use App\Export\Annotation as Exporter;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
@@ -23,6 +24,8 @@ trait ColorTrait
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.color")
*
* @ORM\Column(name="color", type="string", length=7, nullable=true)
* @Assert\Length(min=4, max=7)
*/

View File

@@ -9,6 +9,7 @@
namespace App\Entity;
use App\Export\Annotation as Exporter;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -25,6 +26,9 @@ use Symfony\Component\Validator\Constraints as Assert;
* @ORM\Entity(repositoryClass="App\Repository\CustomerRepository")
*
* @Serializer\ExclusionPolicy("all")
*
* @Exporter\Order({"id", "name", "company", "number", "vatId", "address", "contact","email", "phone", "mobile", "fax", "homepage", "country", "currency", "timezone", "budget", "timeBudget", "color", "visible", "teams", "comment"})
* @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array")
*/
class Customer implements EntityWithMetaFields
{
@@ -36,6 +40,8 @@ class Customer implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.id", type="integer")
*
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
@@ -47,6 +53,8 @@ class Customer implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.name")
*
* @ORM\Column(name="name", type="string", length=150, nullable=false)
* @Assert\NotBlank()
* @Assert\Length(min=2, max=150, allowEmptyString=false)
@@ -58,6 +66,8 @@ class Customer implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
*
* @Exporter\Expose(label="label.number")
*
* @ORM\Column(name="number", type="string", length=50, nullable=true)
* @Assert\Length(max=50)
*/
@@ -68,6 +78,8 @@ class Customer implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
*
* @Exporter\Expose(label="label.comment")
*
* @ORM\Column(name="comment", type="text", nullable=true)
*/
private $comment;
@@ -77,6 +89,8 @@ class Customer implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.visible", type="boolean")
*
* @ORM\Column(name="visible", type="boolean", nullable=false)
* @Assert\NotNull()
*/
@@ -87,6 +101,8 @@ class Customer implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
*
* @Exporter\Expose(label="label.company")
*
* @ORM\Column(name="company", type="string", length=255, nullable=true)
* @Assert\Length(max=255)
*/
@@ -97,6 +113,8 @@ class Customer implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
*
* @Exporter\Expose(label="label.vat_id")
*
* @ORM\Column(name="vat_id", type="string", length=50, nullable=true)
* @Assert\Length(max=50)
*/
@@ -107,6 +125,8 @@ class Customer implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
*
* @Exporter\Expose(label="label.contact")
*
* @ORM\Column(name="contact", type="string", length=255, nullable=true)
* @Assert\Length(max=255)
*/
@@ -117,6 +137,8 @@ class Customer implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
*
* @Exporter\Expose(label="label.address")
*
* @ORM\Column(name="address", type="text", nullable=true)
*/
private $address;
@@ -126,6 +148,8 @@ class Customer implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
*
* @Exporter\Expose(label="label.country")
*
* @ORM\Column(name="country", type="string", length=2, nullable=false)
* @Assert\NotBlank()
* @Assert\Length(max=2)
@@ -137,6 +161,8 @@ class Customer implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Customer"})
*
* @Exporter\Expose(label="label.currency")
*
* @ORM\Column(name="currency", type="string", length=3, nullable=false)
* @Assert\NotBlank()
* @Assert\Length(max=3)
@@ -148,6 +174,8 @@ class Customer implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
*
* @Exporter\Expose(label="label.phone")
*
* @ORM\Column(name="phone", type="string", length=255, nullable=true)
* @Assert\Length(max=255)
*/
@@ -158,6 +186,8 @@ class Customer implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
*
* @Exporter\Expose(label="label.fax")
*
* @ORM\Column(name="fax", type="string", length=255, nullable=true)
* @Assert\Length(max=255)
*/
@@ -168,6 +198,8 @@ class Customer implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
*
* @Exporter\Expose(label="label.mobile")
*
* @ORM\Column(name="mobile", type="string", length=255, nullable=true)
* @Assert\Length(max=255)
*/
@@ -182,6 +214,8 @@ class Customer implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
*
* @Exporter\Expose(label="label.email")
*
* @ORM\Column(name="email", type="string", length=255, nullable=true)
* @Assert\Length(max=254)
*/
@@ -192,6 +226,8 @@ class Customer implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
*
* @Exporter\Expose(label="label.homepage")
*
* @ORM\Column(name="homepage", type="string", length=255, nullable=true)
* @Assert\Length(max=255)
*/
@@ -206,6 +242,8 @@ class Customer implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
*
* @Exporter\Expose(label="label.timezone")
*
* @ORM\Column(name="timezone", type="string", length=64, nullable=false)
* @Assert\NotBlank()
* @Assert\Length(max=64)
@@ -216,25 +254,29 @@ class Customer implements EntityWithMetaFields
use ColorTrait;
/**
* The total monetary budget, will be zero if unconfigured.
* The total monetary budget, will be zero if not configured.
*
* @var float
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
*
* @ Exporter\Expose(label="label.budget")
*
* @ORM\Column(name="budget", type="float", nullable=false)
* @Assert\NotNull()
*/
private $budget = 0.00;
/**
* The time budget in seconds, will be be zero if unconfigured.
* The time budget in seconds, will be be zero if not configured.
*
* @var int
*
* @Serializer\Expose()
* @Serializer\Groups({"Customer_Entity"})
*
* @ Exporter\Expose(label="label.timeBudget", type="duration")
*
* @ORM\Column(name="time_budget", type="integer", nullable=false)
* @Assert\NotNull()
*/

View File

@@ -9,6 +9,7 @@
namespace App\Entity;
use App\Export\Annotation as Exporter;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -46,6 +47,10 @@ use Symfony\Component\Validator\Constraints as Assert;
* @Serializer\Groups({"Project", "Team", "Not_Expanded"})
* }
* )
*
* @Exporter\Order({"id", "name", "customer", "orderNumber", "orderDate", "start", "end", "budget", "timeBudget", "color", "visible", "teams", "comment"})
* @Exporter\Expose("customer", label="label.customer", exp="object.getCustomer() === null ? null : object.getCustomer().getName()")
* @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array")
*/
class Project implements EntityWithMetaFields
{
@@ -57,6 +62,8 @@ class Project implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.id", type="integer")
*
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
@@ -84,6 +91,8 @@ class Project implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.name")
*
* @ORM\Column(name="name", type="string", length=150, nullable=false)
* @Assert\NotNull()
* @Assert\Length(min=2, max=150, allowEmptyString=false)
@@ -97,6 +106,8 @@ class Project implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Project_Entity"})
*
* @Exporter\Expose(label="label.orderNumber")
*
* @ORM\Column(name="order_number", type="text", length=20, nullable=true)
* @Assert\Length(max=20)
*/
@@ -108,6 +119,8 @@ class Project implements EntityWithMetaFields
* @Serializer\Groups({"Project_Entity"})
* @Serializer\Type(name="DateTime")
*
* @Exporter\Expose(label="label.orderDate", type="datetime")
*
* @ORM\Column(name="order_date", type="datetime", nullable=true)
*/
private $orderDate;
@@ -118,6 +131,8 @@ class Project implements EntityWithMetaFields
* @Serializer\Groups({"Project"})
* @Serializer\Type(name="DateTime")
*
* @Exporter\Expose(label="label.project_start", type="datetime")
*
* @ORM\Column(name="start", type="datetime", nullable=true)
*/
private $start;
@@ -128,6 +143,8 @@ class Project implements EntityWithMetaFields
* @Serializer\Groups({"Project"})
* @Serializer\Type(name="DateTime")
*
* @Exporter\Expose(label="label.project_end", type="datetime")
*
* @ORM\Column(name="end", type="datetime", nullable=true)
*/
private $end;
@@ -149,6 +166,8 @@ class Project implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Project_Entity"})
*
* @Exporter\Expose(label="label.comment")
*
* @ORM\Column(name="comment", type="text", nullable=true)
*/
private $comment;
@@ -158,6 +177,8 @@ class Project implements EntityWithMetaFields
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.visible", type="boolean")
*
* @ORM\Column(name="visible", type="boolean", nullable=false)
* @Assert\NotNull()
*/
@@ -167,25 +188,29 @@ class Project implements EntityWithMetaFields
use ColorTrait;
/**
* The total monetary budget, will be zero if unconfigured.
* The total monetary budget, will be zero if not configured.
*
* @var float
*
* @Serializer\Expose()
* @Serializer\Groups({"Project_Entity"})
*
* @ Exporter\Expose(label="label.budget")
*
* @ORM\Column(name="budget", type="float", nullable=false)
* @Assert\NotNull()
*/
private $budget = 0.00;
/**
* The time budget in seconds, will be be zero if unconfigured.
* The time budget in seconds, will be be zero if not configured.
*
* @var int
*
* @Serializer\Expose()
* @Serializer\Groups({"Project_Entity"})
*
* @ Exporter\Expose(label="label.timeBudget", type="duration")
*
* @ORM\Column(name="time_budget", type="integer", nullable=false)
* @Assert\NotNull()
*/

View File

@@ -9,6 +9,7 @@
namespace App\Entity;
use App\Export\Annotation as Exporter;
use App\Utils\StringHelper;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -49,6 +50,16 @@ use Symfony\Component\Validator\Constraints as Assert;
* @Serializer\Groups({"User_Entity"})
* }
* )
*
* @Exporter\Order({"id", "username", "alias", "title", "email", "last_login", "language", "timezone", "active", "registeredAt", "roles", "teams"})
* @Exporter\Expose("email", label="label.email", exp="object.getEmail()")
* @Exporter\Expose("username", label="label.username", exp="object.getUsername()")
* @Exporter\Expose("timezone", label="label.timezone", exp="object.getTimezone()")
* @Exporter\Expose("language", label="label.language", exp="object.getLanguage()")
* @Exporter\Expose("last_login", label="label.lastLogin", exp="object.getLastLogin()", type="datetime")
* @Exporter\Expose("roles", label="label.roles", exp="object.getRoles()", type="array")
* @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array")
* @Exporter\Expose("active", label="label.active", exp="object.isEnabled()", type="boolean")
*/
class User extends BaseUser implements UserInterface
{
@@ -73,6 +84,8 @@ class User extends BaseUser implements UserInterface
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.id", type="integer")
*
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(name="id", type="integer")
@@ -86,6 +99,8 @@ class User extends BaseUser implements UserInterface
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.alias")
*
* @ORM\Column(name="alias", type="string", length=60, nullable=true)
* @Assert\Length(max=60)
*/
@@ -95,6 +110,8 @@ class User extends BaseUser implements UserInterface
*
* @var \DateTime
*
* @Exporter\Expose(label="profile.registration_date", type="datetime")
*
* @ORM\Column(name="registration_date", type="datetime", nullable=true)
*/
private $registeredAt;
@@ -106,6 +123,8 @@ class User extends BaseUser implements UserInterface
* @Serializer\Expose()
* @Serializer\Groups({"User_Entity"})
*
* @Exporter\Expose(label="label.title")
*
* @ORM\Column(name="title", type="string", length=50, nullable=true)
* @Assert\Length(max=50)
*/
@@ -179,7 +198,7 @@ class User extends BaseUser implements UserInterface
* This flag will be initialized in UserEnvironmentSubscriber.
*
* @var bool|null
* @internal has no database mapping. as the value is calculated from a permission
* @internal has no database mapping as the value is calculated from a permission
*/
private $isAllowedToSeeAllData = null;

View File

@@ -0,0 +1,76 @@
<?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\MetaTableTypeInterface;
use App\Repository\Query\BaseQuery;
use Symfony\Contracts\EventDispatcher\Event;
abstract class AbstractMetaDisplayEvent extends Event implements MetaDisplayEventInterface
{
/**
* @var BaseQuery
*/
private $query;
/**
* @var string
*/
private $location;
/**
* @var MetaTableTypeInterface[]
*/
private $fields = [];
public function __construct(BaseQuery $query, string $location)
{
$this->query = $query;
$this->location = $location;
}
/**
* To filter where your meta-field will be displayed, use the query settings.
*
* @return BaseQuery
*/
public function getQuery(): BaseQuery
{
return $this->query;
}
/**
* If you want to filter where your meta-field will be displayed, check the current location.
*
* @return string
*/
public function getLocation(): string
{
return $this->location;
}
/**
* Add a new meta field that should be included.
*
* @param MetaTableTypeInterface $meta
*/
public function addField(MetaTableTypeInterface $meta)
{
$this->fields[] = $meta;
}
/**
* Returns all meta-fields to be included.
*
* @return MetaTableTypeInterface[]
*/
public function getFields(): array
{
return $this->fields;
}
}

View File

@@ -9,68 +9,20 @@
namespace App\Event;
use App\Entity\MetaTableTypeInterface;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\BaseQuery;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Dynamically find possible meta fields for a activity query.
*
* @method ActivityQuery getQuery()
*/
final class ActivityMetaDisplayEvent extends Event implements MetaDisplayEventInterface
final class ActivityMetaDisplayEvent extends AbstractMetaDisplayEvent
{
public const EXPORT = 'export';
public const ACTIVITY = 'activity';
/**
* @var ActivityQuery
*/
private $query;
/**
* @var string
*/
private $location;
/**
* @var MetaTableTypeInterface[]
*/
private $fields = [];
public function __construct(ActivityQuery $query, string $location)
{
$this->query = $query;
$this->location = $location;
}
/**
* If you want to filter where your meta-field will be displayed, use the query settings.
*
* @return ActivityQuery
*/
public function getQuery(): BaseQuery
{
return $this->query;
}
/**
* If you want to filter where your meta-field will be displayed, check the current location.
*
* @return string
*/
public function getLocation(): string
{
return $this->location;
}
public function addField(MetaTableTypeInterface $meta)
{
$this->fields[] = $meta;
}
/**
* @return MetaTableTypeInterface[]
*/
public function getFields(): array
{
return $this->fields;
parent::__construct($query, $location);
}
}

View File

@@ -9,68 +9,20 @@
namespace App\Event;
use App\Entity\MetaTableTypeInterface;
use App\Repository\Query\BaseQuery;
use App\Repository\Query\CustomerQuery;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Dynamically find possible meta fields for a customer query.
*
* @method CustomerQuery getQuery()
*/
final class CustomerMetaDisplayEvent extends Event implements MetaDisplayEventInterface
final class CustomerMetaDisplayEvent extends AbstractMetaDisplayEvent
{
public const EXPORT = 'export';
public const CUSTOMER = 'customer';
/**
* @var CustomerQuery
*/
private $query;
/**
* @var string
*/
private $location;
/**
* @var MetaTableTypeInterface[]
*/
private $fields = [];
public function __construct(CustomerQuery $query, string $location)
{
$this->query = $query;
$this->location = $location;
}
/**
* If you want to filter where your meta-field will be displayed, use the query settings.
*
* @return CustomerQuery
*/
public function getQuery(): BaseQuery
{
return $this->query;
}
/**
* If you want to filter where your meta-field will be displayed, check the current location.
*
* @return string
*/
public function getLocation(): string
{
return $this->location;
}
public function addField(MetaTableTypeInterface $meta)
{
$this->fields[] = $meta;
}
/**
* @return MetaTableTypeInterface[]
*/
public function getFields(): array
{
return $this->fields;
parent::__construct($query, $location);
}
}

View File

@@ -9,68 +9,20 @@
namespace App\Event;
use App\Entity\MetaTableTypeInterface;
use App\Repository\Query\BaseQuery;
use App\Repository\Query\ProjectQuery;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Dynamically find possible meta fields for a project query.
*
* @method ProjectQuery getQuery()
*/
final class ProjectMetaDisplayEvent extends Event implements MetaDisplayEventInterface
final class ProjectMetaDisplayEvent extends AbstractMetaDisplayEvent
{
public const EXPORT = 'export';
public const PROJECT = 'project';
/**
* @var ProjectQuery
*/
private $query;
/**
* @var string
*/
private $location;
/**
* @var MetaTableTypeInterface[]
*/
private $fields = [];
public function __construct(ProjectQuery $query, string $location)
{
$this->query = $query;
$this->location = $location;
}
/**
* If you want to filter where your meta-field will be displayed, use the query settings.
*
* @return ProjectQuery
*/
public function getQuery(): BaseQuery
{
return $this->query;
}
/**
* If you want to filter where your meta-field will be displayed, check the current location.
*
* @return string
*/
public function getLocation(): string
{
return $this->location;
}
public function addField(MetaTableTypeInterface $meta)
{
$this->fields[] = $meta;
}
/**
* @return MetaTableTypeInterface[]
*/
public function getFields(): array
{
return $this->fields;
parent::__construct($query, $location);
}
}

View File

@@ -9,15 +9,14 @@
namespace App\Event;
use App\Entity\MetaTableTypeInterface;
use App\Repository\Query\BaseQuery;
use App\Repository\Query\TimesheetQuery;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Dynamically find possible meta fields for a timesheet query.
*
* @method TimesheetQuery getQuery()
*/
final class TimesheetMetaDisplayEvent extends Event implements MetaDisplayEventInterface
final class TimesheetMetaDisplayEvent extends AbstractMetaDisplayEvent
{
public const EXPORT = 'export';
public const TIMESHEET = 'timesheet';
@@ -25,55 +24,8 @@ final class TimesheetMetaDisplayEvent extends Event implements MetaDisplayEventI
public const TIMESHEET_EXPORT = 'timesheet-export';
public const TEAM_TIMESHEET_EXPORT = 'team-timesheet-export';
/**
* @var TimesheetQuery
*/
private $query;
/**
* @var string
*/
private $location;
/**
* @var MetaTableTypeInterface[]
*/
private $fields = [];
public function __construct(TimesheetQuery $query, string $location)
{
$this->query = $query;
$this->location = $location;
}
/**
* If you want to filter where your meta-field will be displayed, use the query settings.
*
* @return TimesheetQuery
*/
public function getQuery(): BaseQuery
{
return $this->query;
}
/**
* If you want to filter where your meta-field will be displayed, check the current location.
*
* @return string
*/
public function getLocation(): string
{
return $this->location;
}
public function addField(MetaTableTypeInterface $meta)
{
$this->fields[] = $meta;
}
/**
* @return MetaTableTypeInterface[]
*/
public function getFields(): array
{
return $this->fields;
parent::__construct($query, $location);
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Annotation;
use Doctrine\Common\Annotations\Annotation\Enum;
use Doctrine\Common\Annotations\Annotation\Required;
/**
* Annotation class for @Expose().
*
* @Annotation
* @Target({"CLASS", "PROPERTY", "METHOD"})
*/
final class Expose
{
/**
* @var string
* @Required
*/
public $label;
/**
* @var string
*/
public $name;
/**
* @Enum({"string", "datetime", "date", "time", "integer", "float", "duration", "boolean", "array"})
*/
public $type = 'string';
/**
* @var string
*/
public $exp = null;
public function __construct(array $data)
{
if (isset($data['value'])) {
$this->name = $data['value'];
unset($data['value']);
}
foreach ($data as $key => $value) {
if (!property_exists(self::class, $key)) {
throw new \InvalidArgumentException(sprintf('Unknown property "%s" on annotation "%s".', $key, self::class));
}
$this->{$key} = $value;
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Annotation;
/**
* @Annotation
* @Target({"CLASS"})
*/
final class Order
{
/**
* @var array<string>
*/
public $order = [];
public function __construct(array $data)
{
if (isset($data['value'])) {
$this->order = $data['value'];
unset($data['value']);
}
}
}

View File

@@ -0,0 +1,32 @@
<?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\Export\Spreadsheet;
use App\Export\Spreadsheet\Extractor\AnnotationExtractor;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
final class AnnotatedObjectExporter
{
private $spreadsheetExporter;
private $annotationExtractor;
public function __construct(SpreadsheetExporter $spreadsheetExporter, AnnotationExtractor $annotationExtractor)
{
$this->spreadsheetExporter = $spreadsheetExporter;
$this->annotationExtractor = $annotationExtractor;
}
public function export(string $class, array $entries): Spreadsheet
{
$columns = $this->annotationExtractor->extract($class);
return $this->spreadsheetExporter->export($columns, $entries);
}
}

View File

@@ -0,0 +1,30 @@
<?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\Export\Spreadsheet\CellFormatter;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class ArrayFormatter implements CellFormatterInterface
{
public function setFormattedValue(Worksheet $sheet, int $column, int $row, $value): void
{
if (null === $value) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
return;
}
if (!\is_array($value)) {
throw new \InvalidArgumentException('Unsupported value given, only array is supported');
}
$sheet->setCellValueByColumnAndRow($column, $row, implode(';', $value));
}
}

View File

@@ -0,0 +1,30 @@
<?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\Export\Spreadsheet\CellFormatter;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class BooleanFormatter implements CellFormatterInterface
{
public function setFormattedValue(Worksheet $sheet, int $column, int $row, $value): void
{
if (null === $value) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
return;
}
if (!\is_bool($value)) {
throw new \InvalidArgumentException('Unsupported value given, only boolean is supported');
}
$sheet->setCellValueByColumnAndRow($column, $row, $value);
}
}

View File

@@ -0,0 +1,24 @@
<?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\Export\Spreadsheet\CellFormatter;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
interface CellFormatterInterface
{
/**
* @param Worksheet $sheet
* @param int $column
* @param int $row
* @param mixed $value
* @throws \InvalidArgumentException
*/
public function setFormattedValue(Worksheet $sheet, int $column, int $row, $value): void;
}

View File

@@ -0,0 +1,33 @@
<?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\Export\Spreadsheet\CellFormatter;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class DateFormatter implements CellFormatterInterface
{
public function setFormattedValue(Worksheet $sheet, int $column, int $row, $value): void
{
if (null === $value) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
return;
}
if (!$value instanceof \DateTime) {
throw new \InvalidArgumentException('Unsupported value given, only DateTime is supported');
}
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($value));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_DATE_YYYYMMDD2);
}
}

View File

@@ -0,0 +1,34 @@
<?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\Export\Spreadsheet\CellFormatter;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class DateTimeFormatter implements CellFormatterInterface
{
public const DATETIME_FORMAT = 'yyyy-mm-dd hh:mm';
public function setFormattedValue(Worksheet $sheet, int $column, int $row, $value): void
{
if (null === $value) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
return;
}
if (!$value instanceof \DateTime) {
throw new \InvalidArgumentException('Unsupported value given, only DateTime is supported');
}
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($value));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DATETIME_FORMAT);
}
}

View File

@@ -0,0 +1,31 @@
<?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\Export\Spreadsheet\CellFormatter;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class DurationFormatter implements CellFormatterInterface
{
public const DURATION_FORMAT = '[hh]:mm';
public function setFormattedValue(Worksheet $sheet, int $column, int $row, $value): void
{
if (null === $value) {
$value = 0;
}
if (!\is_int($value)) {
throw new \InvalidArgumentException('Unsupported value given, only int is supported');
}
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=%s/86400', $value));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DURATION_FORMAT);
}
}

View File

@@ -0,0 +1,34 @@
<?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\Export\Spreadsheet\CellFormatter;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class TimeFormatter implements CellFormatterInterface
{
public const TIME_FORMAT = 'hh:mm';
public function setFormattedValue(Worksheet $sheet, int $column, int $row, $value): void
{
if (null === $value) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
return;
}
if (!$value instanceof \DateTime) {
throw new \InvalidArgumentException('Unsupported value given, only DateTime is supported');
}
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($value));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::TIME_FORMAT);
}
}

View File

@@ -0,0 +1,39 @@
<?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\Export\Spreadsheet;
final class ColumnDefinition
{
private $label;
private $type;
private $accessor;
public function __construct(string $label, string $type, callable $accessor)
{
$this->label = $label;
$this->type = $type;
$this->accessor = $accessor;
}
public function getLabel(): string
{
return $this->label;
}
public function getType(): string
{
return $this->type;
}
public function getAccessor(): callable
{
return $this->accessor;
}
}

View File

@@ -0,0 +1,36 @@
<?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\Export\Spreadsheet;
use App\Event\MetaDisplayEventInterface;
use App\Export\Spreadsheet\Extractor\AnnotationExtractor;
use App\Export\Spreadsheet\Extractor\MetaFieldExtractor;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
final class EntityWithMetaFieldsExporter
{
private $exporter;
private $annotationExtractor;
private $metaFieldExtractor;
public function __construct(SpreadsheetExporter $exporter, AnnotationExtractor $annotationExtractor, MetaFieldExtractor $metaFieldExtractor)
{
$this->exporter = $exporter;
$this->annotationExtractor = $annotationExtractor;
$this->metaFieldExtractor = $metaFieldExtractor;
}
public function export(string $class, array $entries, MetaDisplayEventInterface $event): Spreadsheet
{
$columns = array_merge($this->annotationExtractor->extract($class), $this->metaFieldExtractor->extract($event));
return $this->exporter->export($columns, $entries);
}
}

View File

@@ -0,0 +1,150 @@
<?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\Export\Spreadsheet\Extractor;
use App\Export\Annotation\Expose;
use App\Export\Annotation\Order;
use App\Export\Spreadsheet\ColumnDefinition;
use Doctrine\Common\Annotations\Reader;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
/**
* @internal
*/
final class AnnotationExtractor implements ExtractorInterface
{
/**
* @var ExpressionLanguage
*/
private $expressionLanguage;
/**
* @var Reader
*/
private $annotationReader;
public function __construct(Reader $annotationReader)
{
$this->annotationReader = $annotationReader;
$this->expressionLanguage = new ExpressionLanguage();
}
/**
* @param string $value
* @return ColumnDefinition[]
* @throws ExtractorException
*/
public function extract($value): array
{
if (!\is_string($value)) {
throw new ExtractorException('AnnotationExtractor needs a class name (string) for work');
}
try {
$reflectionClass = new \ReflectionClass($value);
} catch (\ReflectionException $ex) {
throw new ExtractorException($ex->getMessage());
}
$columns = [];
if (null !== ($definitions = $this->annotationReader->getClassAnnotations($reflectionClass))) {
foreach ($definitions as $definition) {
if ($definition instanceof Order) {
foreach ($definition->order as $columnName) {
$columns[$columnName] = null;
}
}
}
foreach ($definitions as $definition) {
if ($definition instanceof Expose) {
if (null === $definition->name) {
throw new ExtractorException(sprintf('@Expose needs a name attribute on class level hierarchy, check %s::class', $value));
}
if (null === $definition->exp) {
throw new ExtractorException(sprintf('@Expose needs an expression attribute on class level hierarchy, check %s::class', $value));
}
$parsed = $this->expressionLanguage->parse($definition->exp, ['object']);
$columns[$definition->name] = new ColumnDefinition(
$definition->label,
$definition->type,
function ($obj) use ($parsed) {
return $parsed->getNodes()->evaluate([], ['object' => $obj]);
}
);
}
}
}
foreach ($reflectionClass->getProperties() as $property) {
if (null !== ($definitions = $this->annotationReader->getPropertyAnnotations($property))) {
foreach ($definitions as $definition) {
if ($definition instanceof Expose) {
if (null !== $definition->exp) {
throw new ExtractorException(sprintf('@Expose only supports the expression attribute on class level hierarchy, check %s::$%s', $value, $property->getName()));
}
$name = empty($definition->name) ? $property->getName() : $definition->name;
$columns[$name] = new ColumnDefinition(
$definition->label,
$definition->type,
function ($obj) use ($property) {
if (!$property->isPublic()) {
$property->setAccessible(true);
}
return $property->getValue($obj);
}
);
}
}
}
}
foreach ($reflectionClass->getMethods() as $method) {
if (null !== ($definitions = $this->annotationReader->getMethodAnnotations($method))) {
foreach ($definitions as $definition) {
if ($definition instanceof Expose) {
if (null !== $definition->exp) {
throw new ExtractorException(sprintf('@Expose only supports the expression attribute on class level hierarchy, check %s::%s()', $value, $method->getName()));
}
$name = empty($definition->name) ? $method->getName() : $definition->name;
if (\count($method->getParameters()) > 0) {
throw new ExtractorException(sprintf('@Expose does not support method %s::%s(...), it has required parameters.', $value, $method->getName()));
}
$columns[$name] = new ColumnDefinition(
$definition->label,
$definition->type,
function ($obj) use ($method) {
if (!$method->isPublic()) {
$method->setAccessible(true);
}
return $method->invoke($obj);
}
);
}
}
}
}
foreach ($columns as $name => $definition) {
if (null === $definition) {
unset($columns[$name]);
}
}
return array_values($columns);
}
}

View File

@@ -0,0 +1,14 @@
<?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\Export\Spreadsheet\Extractor;
class ExtractorException extends \Exception
{
}

View File

@@ -0,0 +1,25 @@
<?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\Export\Spreadsheet\Extractor;
use App\Export\Spreadsheet\ColumnDefinition;
/**
* Extract ColumnDefinition objects from various sources.
*/
interface ExtractorInterface
{
/**
* @param mixed $value
* @return ColumnDefinition[]
* @throws ExtractorException
*/
public function extract($value): array;
}

View File

@@ -0,0 +1,68 @@
<?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\Export\Spreadsheet\Extractor;
use App\Entity\EntityWithMetaFields;
use App\Event\MetaDisplayEventInterface;
use App\Export\Spreadsheet\ColumnDefinition;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* @internal
*/
final class MetaFieldExtractor implements ExtractorInterface
{
/**
* @var EventDispatcherInterface
*/
private $eventDispatcher;
public function __construct(EventDispatcherInterface $eventDispatcher)
{
$this->eventDispatcher = $eventDispatcher;
}
/**
* @param MetaDisplayEventInterface $value
* @return ColumnDefinition[]
* @throws ExtractorException
*/
public function extract($value): array
{
if (!($value instanceof MetaDisplayEventInterface)) {
throw new ExtractorException('MetaFieldExtractor needs a MetaDisplayEventInterface instance for work');
}
$columns = [];
$this->eventDispatcher->dispatch($value);
foreach ($value->getFields() as $field) {
if (!$field->isVisible()) {
continue;
}
$columns[] = new ColumnDefinition(
$field->getLabel(),
'string',
function (EntityWithMetaFields $entityWithMetaFields) use ($field) {
$meta = $entityWithMetaFields->getMetaField($field->getName());
if (null === $meta) {
return null;
}
return $meta->getValue();
}
);
}
return $columns;
}
}

View File

@@ -0,0 +1,68 @@
<?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\Export\Spreadsheet\Extractor;
use App\Entity\User;
use App\Event\UserPreferenceDisplayEvent;
use App\Export\Spreadsheet\ColumnDefinition;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* @internal
*/
final class UserPreferenceExtractor implements ExtractorInterface
{
/**
* @var EventDispatcherInterface
*/
private $eventDispatcher;
public function __construct(EventDispatcherInterface $eventDispatcher)
{
$this->eventDispatcher = $eventDispatcher;
}
/**
* @param UserPreferenceDisplayEvent $value
* @return ColumnDefinition[]
* @throws ExtractorException
*/
public function extract($value): array
{
if (!($value instanceof UserPreferenceDisplayEvent)) {
throw new ExtractorException('UserPreferenceExtractor needs a UserPreferenceDisplayEvent instance for work');
}
$columns = [];
$this->eventDispatcher->dispatch($value);
foreach ($value->getPreferences() as $field) {
if (!$field->isEnabled()) {
continue;
}
$columns[] = new ColumnDefinition(
$field->getLabel(),
'string',
function (User $user) use ($field) {
$meta = $user->getPreference($field->getName());
if (null === $meta) {
return null;
}
return $meta->getValue();
}
);
}
return $columns;
}
}

View File

@@ -0,0 +1,100 @@
<?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\Export\Spreadsheet;
use App\Export\Spreadsheet\CellFormatter\ArrayFormatter;
use App\Export\Spreadsheet\CellFormatter\BooleanFormatter;
use App\Export\Spreadsheet\CellFormatter\CellFormatterInterface;
use App\Export\Spreadsheet\CellFormatter\DateFormatter;
use App\Export\Spreadsheet\CellFormatter\DateTimeFormatter;
use App\Export\Spreadsheet\CellFormatter\DurationFormatter;
use App\Export\Spreadsheet\CellFormatter\TimeFormatter;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @internal
*/
class SpreadsheetExporter
{
/**
* @var TranslatorInterface
*/
private $translator;
/**
* @var CellFormatterInterface[]
*/
private $formatter = [];
public function __construct(TranslatorInterface $translator)
{
$this->translator = $translator;
$this->registerCellFormatter('datetime', new DateTimeFormatter());
$this->registerCellFormatter('date', new DateFormatter());
$this->registerCellFormatter('time', new TimeFormatter());
$this->registerCellFormatter('duration', new DurationFormatter());
$this->registerCellFormatter('boolean', new BooleanFormatter());
$this->registerCellFormatter('array', new ArrayFormatter());
}
public function registerCellFormatter(string $type, CellFormatterInterface $formatter)
{
$this->formatter[$type] = $formatter;
}
/**
* @param ColumnDefinition[] $columns
* @param array $entries
* @return Spreadsheet
* @throws \PhpOffice\PhpSpreadsheet\Exception
*/
public function export(array $columns, array $entries): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
// Set default row height to automatic, so we can specify wrap text columns later on
// without bloating the output file as we would need to store stylesheet info for every cell.
// LibreOffice is still not considering this flag, @see https://github.com/PHPOffice/PHPExcel/issues/588
// with no solution implemented so nothing we can do about it there.
$sheet->getDefaultRowDimension()->setRowHeight(-1);
$recordsHeaderColumn = 1;
$recordsHeaderRow = 1;
foreach ($columns as $settings) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($settings->getLabel()));
}
$entryHeaderRow = $recordsHeaderRow + 1;
foreach ($entries as $entry) {
$entryHeaderColumn = 1;
foreach ($columns as $settings) {
$value = \call_user_func($settings->getAccessor(), $entry);
if (!\array_key_exists($settings->getType(), $this->formatter)) {
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $value);
} else {
$formatter = $this->formatter[$settings->getType()];
$formatter->setFormattedValue($sheet, $entryHeaderColumn, $entryHeaderRow, $value);
}
$entryHeaderColumn++;
}
$entryHeaderRow++;
}
return $spreadsheet;
}
}

View File

@@ -0,0 +1,47 @@
<?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\Export\Spreadsheet;
use App\Entity\User;
use App\Event\UserPreferenceDisplayEvent;
use App\Export\Spreadsheet\Extractor\AnnotationExtractor;
use App\Export\Spreadsheet\Extractor\UserPreferenceExtractor;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
final class UserExporter
{
private $exporter;
private $annotationExtractor;
private $userPreferenceExtractor;
public function __construct(SpreadsheetExporter $exporter, AnnotationExtractor $annotationExtractor, UserPreferenceExtractor $userPreferenceExtractor)
{
$this->exporter = $exporter;
$this->annotationExtractor = $annotationExtractor;
$this->userPreferenceExtractor = $userPreferenceExtractor;
}
/**
* @param User[] $entries
* @param UserPreferenceDisplayEvent $event
* @return Spreadsheet
* @throws Extractor\ExtractorException
* @throws \PhpOffice\PhpSpreadsheet\Exception
*/
public function export(array $entries, UserPreferenceDisplayEvent $event): Spreadsheet
{
$columns = array_merge(
$this->annotationExtractor->extract(User::class),
$this->userPreferenceExtractor->extract($event)
);
return $this->exporter->export($columns, $entries);
}
}

View File

@@ -0,0 +1,71 @@
<?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\Export\Spreadsheet\Writer;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
class BinaryFileResponseWriter implements WriterInterface
{
/**
* @var WriterInterface
*/
private $writer;
/**
* @var string
*/
private $prefix;
/**
* @param WriterInterface $writer
* @param string $prefix is only urlencoded but not validated and can break the response if you pass in invalid character
*/
public function __construct(WriterInterface $writer, string $prefix)
{
$this->writer = $writer;
$this->prefix = urlencode($prefix);
}
public function getFileExtension(): string
{
return $this->writer->getFileExtension();
}
public function getContentType(): string
{
return $this->writer->getContentType();
}
/**
* {@inheritdoc}
*/
public function save(Spreadsheet $spreadsheet, array $options = []): \SplFileInfo
{
return $this->writer->save($spreadsheet, $options);
}
public function getFileResponse(Spreadsheet $spreadsheet, array $options = []): BinaryFileResponse
{
$file = $this->save($spreadsheet, $options);
$filename = $this->prefix . '_' . (new \DateTime())->format('YmdHim') . '.' . $this->writer->getFileExtension();
$response = new BinaryFileResponse($file);
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
$response->headers->set('Content-Type', $this->getContentType());
$response->headers->set('Content-Disposition', $disposition);
$response->deleteFileAfterSend(true);
return $response;
}
}

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\Export\Spreadsheet\Writer;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
interface WriterInterface
{
public function getFileExtension(): string;
public function getContentType(): string;
/**
* Save the given spreadsheet
*
* @param Spreadsheet $spreadsheet
* @param array $options
* @return \SplFileInfo
*/
public function save(Spreadsheet $spreadsheet, array $options = []): \SplFileInfo;
}

View File

@@ -0,0 +1,90 @@
<?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\Export\Spreadsheet\Writer;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
class XlsxWriter implements WriterInterface
{
public function getFileExtension(): string
{
return 'xlsx';
}
/**
* @return string
*/
public function getContentType(): string
{
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
}
/**
* Options:
* - freeze (string, default: null) Coordinate of a column to freeze, like: D2
* - autofilter (bool, default true) Enable auto filter for header row
*
* @param Spreadsheet $spreadsheet
* @param array $options
* @return \SplFileInfo
* @throws \Exception
*/
public function save(Spreadsheet $spreadsheet, array $options = []): \SplFileInfo
{
$options = array_merge(['autofilter' => true, 'freeze' => null], $options);
$filename = tempnam(sys_get_temp_dir(), 'kimai-export-xlsx');
if (false === $filename) {
throw new \Exception('Could not open temporary file');
}
// Store expensive calculations for later
$sheet = $spreadsheet->getActiveSheet();
$highestRow = $sheet->getHighestRow();
$highestColumn = $sheet->getHighestColumn();
// Enable auto filter for header row
if (false !== $options['autofilter']) {
$sheet->setAutoFilter('A1:' . $highestColumn . '1');
}
// Freeze first row and date & time columns for easier navigation
if (!empty($options['freeze'])) {
$sheet->freezePane($options['freeze']);
}
/** @var string $column */
foreach (range('A', $highestColumn) as $column) {
// We default to a reasonable auto-width decided by the client,
// sadly ->getDefaultColumnDimension() is not supported so it needs
// to be specific about what column should be auto sized.
$col = $sheet->getColumnDimension($column);
// If no other width is specified (which defaults to -1)
if ((int) $col->getWidth() === -1) {
$col->setAutoSize(true);
}
}
// Text inside cells should be top left
$sheet
->getStyle('A2:' . $highestColumn . $highestRow)
->getAlignment()
->setVertical(Alignment::VERTICAL_TOP)
->setHorizontal(Alignment::HORIZONTAL_LEFT);
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$writer->save($filename);
return new \SplFileInfo($filename);
}
}